Skip to content

Commit 94abfa7

Browse files
authored
Doctor: convert read-only health checks (#83198)
* feat(doctor): convert read-only health checks * fix(doctor): keep read-only conversion gates green * fix(doctor): preserve health repair preview contract * fix(doctor): defer session snapshot lint target * fix(doctor): avoid false-clean lint placeholders * test(doctor): type conversion target registry check
1 parent c92ebd6 commit 94abfa7

14 files changed

Lines changed: 1141 additions & 72 deletions

src/commands/doctor-cron.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import os from "node:os";
33
import path from "node:path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import type { OpenClawConfig } from "../config/config.js";
6-
import { maybeRepairLegacyCronStore, noteLegacyWhatsAppCrontabHealthCheck } from "./doctor-cron.js";
6+
import {
7+
collectLegacyWhatsAppCrontabHealthWarning,
8+
maybeRepairLegacyCronStore,
9+
noteLegacyWhatsAppCrontabHealthCheck,
10+
} from "./doctor-cron.js";
711

812
type TerminalNote = (message: string, title?: string) => void;
913

@@ -536,7 +540,25 @@ describe("maybeRepairLegacyCronStore", () => {
536540
});
537541
});
538542

539-
describe("noteLegacyWhatsAppCrontabHealthCheck", () => {
543+
describe("legacy WhatsApp crontab health check", () => {
544+
it("collects a warning about legacy ensure-whatsapp crontab entries on Linux", async () => {
545+
const warning = await collectLegacyWhatsAppCrontabHealthWarning({
546+
platform: "linux",
547+
readCrontab: async () => ({
548+
stdout: [
549+
"# keep comments ignored",
550+
"*/5 * * * * ~/.openclaw/bin/ensure-whatsapp.sh >> ~/.openclaw/logs/whatsapp-health.log 2>&1",
551+
"0 9 * * * /usr/bin/true",
552+
"",
553+
].join("\n"),
554+
}),
555+
});
556+
557+
expect(warning).toContain("Legacy WhatsApp crontab health check detected");
558+
expect(warning).toContain("systemd user bus environment is missing");
559+
expect(warning).toContain("Matched 1 entry");
560+
});
561+
540562
it("warns about legacy ensure-whatsapp crontab entries on Linux", async () => {
541563
await noteLegacyWhatsAppCrontabHealthCheck({
542564
platform: "linux",

src/commands/doctor-cron.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -287,37 +287,46 @@ function findLegacyWhatsAppHealthCrontabLines(crontab: unknown): string[] {
287287
.filter((line) => LEGACY_WHATSAPP_HEALTH_SCRIPT_RE.test(line));
288288
}
289289

290-
export async function noteLegacyWhatsAppCrontabHealthCheck(
290+
export async function collectLegacyWhatsAppCrontabHealthWarning(
291291
params: {
292292
platform?: NodeJS.Platform;
293293
readCrontab?: CrontabReader;
294294
} = {},
295-
): Promise<void> {
295+
): Promise<string | null> {
296296
if ((params.platform ?? process.platform) !== "linux") {
297-
return;
297+
return null;
298298
}
299299

300300
let crontab: unknown;
301301
try {
302302
crontab = (await (params.readCrontab ?? readUserCrontab)()).stdout;
303303
} catch {
304-
return;
304+
return null;
305305
}
306306

307307
const legacyLines = findLegacyWhatsAppHealthCrontabLines(crontab);
308308
if (legacyLines.length === 0) {
309-
return;
309+
return null;
310310
}
311311

312-
note(
313-
[
314-
"Legacy WhatsApp crontab health check detected.",
315-
"`~/.openclaw/bin/ensure-whatsapp.sh` is not maintained by current OpenClaw and can misreport `Gateway inactive` from cron when the systemd user bus environment is missing.",
316-
`Remove the stale crontab entry with ${formatCliCommand("crontab -e")}; use ${formatCliCommand("openclaw channels status --probe")}, ${formatCliCommand("openclaw doctor")}, and ${formatCliCommand("openclaw gateway status")} for current health checks.`,
317-
`Matched ${pluralize(legacyLines.length, "entry")}.`,
318-
].join("\n"),
319-
"Cron",
320-
);
312+
return [
313+
"Legacy WhatsApp crontab health check detected.",
314+
"`~/.openclaw/bin/ensure-whatsapp.sh` is not maintained by current OpenClaw and can misreport `Gateway inactive` from cron when the systemd user bus environment is missing.",
315+
`Remove the stale crontab entry with ${formatCliCommand("crontab -e")}; use ${formatCliCommand("openclaw channels status --probe")}, ${formatCliCommand("openclaw doctor")}, and ${formatCliCommand("openclaw gateway status")} for current health checks.`,
316+
`Matched ${pluralize(legacyLines.length, "entry")}.`,
317+
].join("\n");
318+
}
319+
320+
export async function noteLegacyWhatsAppCrontabHealthCheck(
321+
params: {
322+
platform?: NodeJS.Platform;
323+
readCrontab?: CrontabReader;
324+
} = {},
325+
): Promise<void> {
326+
const warning = await collectLegacyWhatsAppCrontabHealthWarning(params);
327+
if (warning) {
328+
note(warning, "Cron");
329+
}
321330
}
322331

323332
export async function maybeRepairLegacyCronStore(params: {

src/commands/doctor-lint.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ const mocks = vi.hoisted(() => ({
77
readConfigFileSnapshot: vi.fn(),
88
}));
99

10-
vi.mock("../config/config.js", () => ({
10+
vi.mock("../config/config.js", async (importOriginal) => ({
11+
...(await importOriginal<typeof import("../config/config.js")>()),
1112
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
1213
}));
1314

@@ -37,6 +38,7 @@ describe("runDoctorLintCli", () => {
3738
const exitCode = await runDoctorLintCli(runtime, {
3839
json: true,
3940
severityMin: "error",
41+
onlyIds: ["core/doctor/final-config-validation"],
4042
});
4143

4244
expect(exitCode).toBe(0);
@@ -61,12 +63,11 @@ describe("runDoctorLintCli", () => {
6163
try {
6264
const exitCode = await runDoctorLintCli(runtime, {
6365
severityMin: "error",
66+
onlyIds: ["core/doctor/final-config-validation"],
6467
});
6568

6669
expect(exitCode).toBe(0);
67-
expect(String(stdout.mock.calls[0]?.[0])).toBe(
68-
"doctor --lint: ran 6 check(s), 0 finding(s)\n",
69-
);
70+
expect(String(stdout.mock.calls[0]?.[0])).toContain("0 finding(s)");
7071
expect(String(stdout.mock.calls[1]?.[0])).toBe(" no findings\n");
7172
} finally {
7273
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsTTY });
@@ -107,6 +108,39 @@ describe("runDoctorLintCli", () => {
107108
}
108109
});
109110

111+
it("rejects unknown --only health check ids instead of reporting a false-clean run", async () => {
112+
mocks.readConfigFileSnapshot.mockResolvedValue({
113+
exists: true,
114+
valid: true,
115+
config: {},
116+
path: "/tmp/openclaw.json",
117+
});
118+
119+
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
120+
try {
121+
const exitCode = await runDoctorLintCli(runtime, {
122+
json: true,
123+
onlyIds: ["core/doctor/session-locks"],
124+
});
125+
126+
expect(exitCode).toBe(1);
127+
const payload = JSON.parse(String(stdout.mock.calls.at(-1)?.[0]));
128+
expect(payload).toMatchObject({
129+
ok: false,
130+
checksRun: 0,
131+
findings: [
132+
{
133+
checkId: "core/doctor/lint-selection",
134+
severity: "error",
135+
path: "core/doctor/session-locks",
136+
},
137+
],
138+
});
139+
} finally {
140+
stdout.mockRestore();
141+
}
142+
});
143+
110144
it("rejects invalid severity thresholds", async () => {
111145
await expect(runDoctorLintCli(runtime, { severityMin: "warnng" })).rejects.toThrow(
112146
"Invalid --severity-min value",

src/commands/doctor-platform-notes.launchctl-env-overrides.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, expect, it, vi } from "vitest";
22
import type { OpenClawConfig } from "../config/config.js";
33
import {
4+
collectMacLaunchAgentOverrideWarning,
5+
collectMacLaunchctlGatewayEnvOverrideWarning,
6+
collectMacStaleOpenClawUpdateLaunchdJobsWarning,
47
noteMacLaunchctlGatewayEnvOverrides,
58
noteMacStaleOpenClawUpdateLaunchdJobs,
69
} from "./doctor-platform-notes.js";
@@ -14,6 +17,29 @@ function requireNoteCall(noteFn: { mock: { calls: unknown[][] } }, index = 0): u
1417
}
1518

1619
describe("noteMacLaunchctlGatewayEnvOverrides", () => {
20+
it("collects clear unsetenv instructions for token override", async () => {
21+
const getenv = vi.fn(async (name: string) =>
22+
name === "OPENCLAW_GATEWAY_TOKEN" ? "launchctl-token" : undefined,
23+
);
24+
const cfg = {
25+
gateway: {
26+
auth: {
27+
token: "config-token",
28+
},
29+
},
30+
} as OpenClawConfig;
31+
32+
const warning = await collectMacLaunchctlGatewayEnvOverrideWarning(cfg, {
33+
platform: "darwin",
34+
getenv,
35+
});
36+
37+
expect(warning).toContain("Host-wide launchctl gateway auth overrides detected");
38+
expect(warning).toContain("OPENCLAW_GATEWAY_TOKEN");
39+
expect(warning).toContain("launchctl unsetenv OPENCLAW_GATEWAY_TOKEN");
40+
expect(warning).not.toContain("OPENCLAW_GATEWAY_PASSWORD");
41+
});
42+
1743
it("prints clear unsetenv instructions for token override", async () => {
1844
const noteFn = vi.fn();
1945
const getenv = vi.fn(async (name: string) =>
@@ -96,6 +122,26 @@ describe("noteMacLaunchctlGatewayEnvOverrides", () => {
96122
});
97123

98124
describe("noteMacStaleOpenClawUpdateLaunchdJobs", () => {
125+
it("collects stale updater job cleanup guidance on macOS", async () => {
126+
const findJobs = vi.fn(async () => [
127+
{
128+
label: "ai.openclaw.update.2026.5.12",
129+
lastExitStatus: 127,
130+
},
131+
]);
132+
133+
const warning = await collectMacStaleOpenClawUpdateLaunchdJobsWarning({
134+
platform: "darwin",
135+
findJobs,
136+
});
137+
138+
expect(findJobs).toHaveBeenCalledTimes(1);
139+
expect(warning).toContain("Stale OpenClaw updater launchd job(s) detected");
140+
expect(warning).toContain("ai.openclaw.update.2026.5.12");
141+
expect(warning).toContain("launchctl remove <label>");
142+
expect(warning).toContain("openclaw gateway restart");
143+
});
144+
99145
it("prints stale updater job cleanup guidance on macOS", async () => {
100146
const noteFn = vi.fn();
101147
const findJobs = vi.fn(async () => [
@@ -133,3 +179,27 @@ describe("noteMacStaleOpenClawUpdateLaunchdJobs", () => {
133179
expect(noteFn).not.toHaveBeenCalled();
134180
});
135181
});
182+
183+
describe("collectMacLaunchAgentOverrideWarning", () => {
184+
it("collects guidance when launch agent writes are disabled", () => {
185+
const warning = collectMacLaunchAgentOverrideWarning({
186+
platform: "darwin",
187+
homeDir: "/Users/tester",
188+
exists: (candidate) => candidate.includes("disable-launchagent"),
189+
});
190+
191+
expect(warning).toContain("LaunchAgent writes are disabled");
192+
expect(warning).toContain("rm ");
193+
expect(warning).toContain("disable-launchagent");
194+
});
195+
196+
it("does nothing when launch agent writes are not disabled", () => {
197+
expect(
198+
collectMacLaunchAgentOverrideWarning({
199+
platform: "darwin",
200+
homeDir: "/Users/tester",
201+
exists: () => false,
202+
}),
203+
).toBeNull();
204+
});
205+
});

0 commit comments

Comments
 (0)