diff --git a/CHANGELOG.md b/CHANGELOG.md index bc992cc3c7b..65e7a44a74d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index 171372e4495..a96c9e8722a 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -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(); + }); }); diff --git a/src/commands/doctor-cron.ts b/src/commands/doctor-cron.ts index 0cda4155abf..a8258168b1e 100644 --- a/src/commands/doctor-cron.ts +++ b/src/commands/doctor-cron.ts @@ -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 { diff --git a/src/terminal/note.ts b/src/terminal/note.ts index 81d38fde18c..bb64ad3efb5 100644 --- a/src/terminal/note.ts +++ b/src/terminal/note.ts @@ -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; } diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index c61082a661c..dd28b82043b 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -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(""); + }); });