fix(doctor): tolerate malformed crontab output (#78112)

Fixes #77773.

Co-authored-by: brokemac79 <martin_cleary@yahoo.co.uk>
This commit is contained in:
brokemac79
2026-05-11 13:15:57 +01:00
committed by GitHub
parent a57691cdcb
commit ccdaf1875a
5 changed files with 77 additions and 9 deletions

View File

@@ -588,6 +588,7 @@ Docs: https://docs.openclaw.ai
- Network/runtime: avoid importing Undici's package dispatcher during no-proxy timeout bootstrap so external channel plugin fetch requests with explicit Content-Length keep working. Fixes #78007. Thanks @shakkernerd.
- Status/doctor: treat a single healthy OpenClaw Gateway listener on loopback, LAN, or wildcard bind as the expected configured gateway instead of warning that the port is already in use. Fixes #77939. Thanks @GitHoubi and @brokemac79.
- Agents/TTS: send media-bearing block replies directly when block streaming is off, so agent `tts` tool audio attached to a final text reply is delivered instead of being consumed before final Telegram/media delivery. Thanks @Conan-Scott.
- Doctor: avoid crashing on partial Linux environments when the legacy crontab probe or terminal note wrapper receives missing or non-string output. Fixes #77773. Thanks @brokemac79 and @blackflame7983.
- Gateway/performance: reuse the current compatible plugin metadata snapshot across hot read-only status, channel, auth, skills, and embedded agent settings paths, avoiding repeated synchronous plugin metadata scans during Gateway activity. Fixes #77983. Thanks @shakkernerd.
- Tasks/maintenance: prune stale cron run session registry entries while preserving running cron jobs and non-cron sessions. Fixes #73867. Thanks @brokemac79.
- Plugins: dispatch cached descriptor-backed tools by the resolved runtime tool name for unnamed factories, fixing multi-tool plugins whose shared manifest contracts exposed sibling tools but failed at execution. Fixes #78671. Thanks @zanni098.

View File

@@ -422,4 +422,33 @@ describe("noteLegacyWhatsAppCrontabHealthCheck", () => {
expect(noteMock).not.toHaveBeenCalled();
});
it("ignores malformed crontab output instead of crashing", async () => {
await expect(
noteLegacyWhatsAppCrontabHealthCheck({
platform: "linux",
readCrontab: async () => ({
stdout: undefined,
}),
}),
).resolves.toBeUndefined();
await expect(
noteLegacyWhatsAppCrontabHealthCheck({
platform: "linux",
readCrontab: async () => ({
stdout: 12345,
}),
}),
).resolves.toBeUndefined();
await expect(
noteLegacyWhatsAppCrontabHealthCheck({
platform: "linux",
readCrontab: async () => ({
stdout: { lines: ["*/5 * * * * ~/.openclaw/bin/ensure-whatsapp.sh"] },
}),
}),
).resolves.toBeUndefined();
expect(noteMock).not.toHaveBeenCalled();
});
});

View File

@@ -22,7 +22,7 @@ type CronDoctorOutcome = {
warnings: string[];
};
type CrontabReader = () => Promise<{ stdout: string; stderr?: string }>;
type CrontabReader = () => Promise<{ stdout?: unknown; stderr?: unknown }>;
const execFileAsync = promisify(execFile);
const LEGACY_WHATSAPP_HEALTH_SCRIPT_RE =
@@ -153,8 +153,21 @@ async function readUserCrontab(): Promise<{ stdout: string; stderr?: string }> {
};
}
function findLegacyWhatsAppHealthCrontabLines(crontab: string): string[] {
return crontab
function coerceCrontabText(crontab: unknown): string {
if (typeof crontab === "string") {
return crontab;
}
if (crontab == null) {
return "";
}
if (typeof crontab === "number" || typeof crontab === "boolean" || typeof crontab === "bigint") {
return String(crontab);
}
return "";
}
function findLegacyWhatsAppHealthCrontabLines(crontab: unknown): string[] {
return coerceCrontabText(crontab)
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"))
@@ -171,7 +184,7 @@ export async function noteLegacyWhatsAppCrontabHealthCheck(
return;
}
let crontab: string;
let crontab: unknown;
try {
crontab = (await (params.readCrontab ?? readUserCrontab)()).stdout;
} catch {

View File

@@ -147,13 +147,30 @@ function wrapLine(line: string, maxWidth: number): string[] {
return lines;
}
function coerceNoteMessage(message: unknown): string {
if (typeof message === "string") {
return message;
}
if (message == null) {
return "";
}
if (typeof message === "number" || typeof message === "boolean" || typeof message === "bigint") {
return String(message);
}
if (message instanceof Error) {
return message.message ? `${message.name}: ${message.message}` : message.name;
}
return "";
}
export function wrapNoteMessage(
message: string,
message: unknown,
options: { maxWidth?: number; columns?: number } = {},
): string {
const text = coerceNoteMessage(message);
const columns = options.columns ?? resolveNoteColumns(process.stdout.columns);
const maxWidth = options.maxWidth ?? Math.max(40, Math.min(88, columns - 10));
return message
return text
.split("\n")
.flatMap((line) => wrapLine(line, maxWidth))
.join("\n");
@@ -179,7 +196,7 @@ function createNoteOutput(columns: number): NodeJS.WriteStream {
return output;
}
export function note(message: string, title?: string) {
export function note(message: unknown, title?: string) {
if (isSuppressedByEnv(process.env.OPENCLAW_SUPPRESS_NOTES)) {
return;
}

View File

@@ -24,7 +24,7 @@ describe("renderTable", () => {
});
expect(out).toContain("Dashboard");
expect(out).toMatch(/ Dashboard\s+/);
expect(out).toMatch(/[│|] Dashboard\s+[│|]/);
});
it("expands flex columns to fill available width", () => {
@@ -86,7 +86,7 @@ describe("renderTable", () => {
const lines = out.split("\n").filter((line) => line.includes("a"));
for (const line of lines) {
const resetIndex = line.lastIndexOf(reset);
const lastSep = line.lastIndexOf("│");
const lastSep = Math.max(line.lastIndexOf("│"), line.lastIndexOf("|"));
expect(resetIndex).toBeGreaterThan(-1);
expect(lastSep).toBeGreaterThan(resetIndex);
}
@@ -279,4 +279,12 @@ describe("wrapNoteMessage", () => {
expect(resolveNoteColumns(79)).toBe(80);
expect(resolveNoteColumns(120)).toBe(120);
});
it("coerces nullish and non-string note messages before wrapping", () => {
expect(wrapNoteMessage(undefined, { maxWidth: 20, columns: 80 })).toBe("");
expect(wrapNoteMessage(null, { maxWidth: 20, columns: 80 })).toBe("");
expect(wrapNoteMessage(12345, { maxWidth: 20, columns: 80 })).toBe("12345");
expect(wrapNoteMessage(new Error("boom"), { maxWidth: 20, columns: 80 })).toBe("Error: boom");
expect(wrapNoteMessage({ message: "boom" }, { maxWidth: 20, columns: 80 })).toBe("");
});
});