Skip to content

Commit 3630d50

Browse files
authored
Doctor: expose gateway runtime findings (#97075)
* feat(doctor): expose gateway runtime findings * fix(doctor): redact gateway health targets
1 parent 7bbd090 commit 3630d50

5 files changed

Lines changed: 395 additions & 3 deletions

src/flows/doctor-core-checks.runtime.test.ts

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ const mocks = vi.hoisted(() => ({
99
disposeBundleRuntime: vi.fn(),
1010
loadModelCatalog: vi.fn(async (): Promise<Array<Record<string, unknown>>> => []),
1111
normalizeProviderToolSchemasWithPlugin: vi.fn(),
12+
buildGatewayProbeConnectionDetails: vi.fn(),
13+
probeGatewayStatus: vi.fn(),
14+
readGatewayServiceState: vi.fn(),
15+
resolveGatewayService: vi.fn(() => ({ label: "openclaw-gateway" })),
1216
resolvePluginProviders: vi.fn((): Array<Record<string, unknown>> => []),
1317
resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", model: "gpt-5.5" })),
1418
}));
@@ -35,6 +39,19 @@ vi.mock("../agents/agent-tools.js", () => ({
3539
createOpenClawCodingTools: mocks.createOpenClawCodingTools,
3640
}));
3741

42+
vi.mock("../gateway/call.js", () => ({
43+
buildGatewayProbeConnectionDetails: mocks.buildGatewayProbeConnectionDetails,
44+
}));
45+
46+
vi.mock("../cli/daemon-cli/probe.js", () => ({
47+
probeGatewayStatus: mocks.probeGatewayStatus,
48+
}));
49+
50+
vi.mock("../daemon/service.js", () => ({
51+
readGatewayServiceState: mocks.readGatewayServiceState,
52+
resolveGatewayService: mocks.resolveGatewayService,
53+
}));
54+
3855
vi.mock("../plugins/provider-runtime.js", () => ({
3956
inspectProviderToolSchemasWithPlugin: () => [],
4057
normalizeProviderToolSchemasWithPlugin: mocks.normalizeProviderToolSchemasWithPlugin,
@@ -48,8 +65,12 @@ vi.mock("../plugins/providers.runtime.js", () => ({
4865
resolvePluginProviders: mocks.resolvePluginProviders,
4966
}));
5067

51-
const { collectProviderCatalogProjectionFindings, collectRuntimeToolSchemaFindings } =
52-
await import("./doctor-core-checks.runtime.js");
68+
const {
69+
collectGatewayDaemonFindings,
70+
collectGatewayHealthFindings,
71+
collectProviderCatalogProjectionFindings,
72+
collectRuntimeToolSchemaFindings,
73+
} = await import("./doctor-core-checks.runtime.js");
5374

5475
function tool(name: string, parameters: unknown): AnyAgentTool {
5576
return {
@@ -79,6 +100,22 @@ describe("doctor runtime tool schema checks", () => {
79100
mocks.normalizeProviderToolSchemasWithPlugin
80101
.mockReset()
81102
.mockImplementation(({ context }) => context.tools);
103+
mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({
104+
url: "http://127.0.0.1:5829",
105+
});
106+
mocks.probeGatewayStatus.mockReset().mockResolvedValue({
107+
ok: true,
108+
server: { version: "2026.6.26" },
109+
});
110+
mocks.readGatewayServiceState.mockReset().mockResolvedValue({
111+
installed: true,
112+
loaded: true,
113+
running: true,
114+
env: {},
115+
command: { programArguments: ["openclaw", "gateway"], sourcePath: "/tmp/gateway.service" },
116+
runtime: { status: "running" },
117+
});
118+
mocks.resolveGatewayService.mockClear();
82119
mocks.resolvePluginProviders.mockReset().mockReturnValue([]);
83120
mocks.resolveDefaultModelForAgent.mockClear();
84121
});
@@ -503,6 +540,100 @@ describe("doctor runtime tool schema checks", () => {
503540
});
504541
});
505542

543+
describe("doctor gateway runtime checks", () => {
544+
beforeEach(() => {
545+
mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({
546+
url: "http://127.0.0.1:5829",
547+
});
548+
mocks.probeGatewayStatus.mockReset().mockResolvedValue({
549+
ok: true,
550+
server: { version: "2026.6.26" },
551+
});
552+
mocks.readGatewayServiceState.mockReset().mockResolvedValue({
553+
installed: true,
554+
loaded: true,
555+
running: true,
556+
env: {},
557+
command: { programArguments: ["openclaw", "gateway"], sourcePath: "/tmp/gateway.service" },
558+
runtime: { status: "running" },
559+
});
560+
mocks.resolveGatewayService.mockReset().mockReturnValue({ label: "openclaw-gateway" });
561+
});
562+
563+
it("reports unreachable gateway health probes", async () => {
564+
mocks.probeGatewayStatus.mockResolvedValueOnce({
565+
ok: false,
566+
error: "connect ECONNREFUSED 127.0.0.1:5829",
567+
});
568+
569+
await expect(
570+
collectGatewayHealthFindings({ cfg: { gateway: { mode: "local" } } }),
571+
).resolves.toContainEqual({
572+
checkId: "core/doctor/gateway-health",
573+
severity: "warning",
574+
message: "Gateway is not reachable: connect ECONNREFUSED 127.0.0.1:5829",
575+
path: "gateway.mode",
576+
target: "http://127.0.0.1:5829",
577+
fixHint:
578+
"Start the Gateway service or run `openclaw doctor --fix` for service repair prompts.",
579+
});
580+
});
581+
582+
it("redacts sensitive remote gateway URLs from health finding targets", async () => {
583+
mocks.buildGatewayProbeConnectionDetails.mockResolvedValueOnce({
584+
url: "wss://user:[email protected]/rpc?token=secret&safe=value",
585+
});
586+
mocks.probeGatewayStatus.mockResolvedValueOnce({
587+
ok: false,
588+
error: "remote gateway did not answer",
589+
});
590+
591+
const findings = await collectGatewayHealthFindings({
592+
cfg: { gateway: { mode: "remote", remote: { url: "wss://gateway.example.test/rpc" } } },
593+
});
594+
595+
expect(findings).toContainEqual({
596+
checkId: "core/doctor/gateway-health",
597+
severity: "warning",
598+
message: "Gateway is not reachable: remote gateway did not answer",
599+
path: "gateway.remote.url",
600+
target: "wss://***:***@gateway.example.test/rpc?token=***&safe=value",
601+
fixHint: "Verify the remote Gateway URL, network path, TLS settings, and credentials.",
602+
});
603+
expect(JSON.stringify(findings)).not.toContain("user:pass");
604+
expect(JSON.stringify(findings)).not.toContain("token=secret");
605+
});
606+
607+
it("reports missing local gateway daemon service", async () => {
608+
mocks.readGatewayServiceState.mockResolvedValueOnce({
609+
installed: false,
610+
loaded: false,
611+
running: false,
612+
env: {},
613+
command: null,
614+
});
615+
616+
await expect(
617+
collectGatewayDaemonFindings({ cfg: { gateway: { mode: "local" } } }),
618+
).resolves.toContainEqual({
619+
checkId: "core/doctor/gateway-daemon",
620+
severity: "warning",
621+
message: "Gateway service is not installed.",
622+
path: "gateway.mode",
623+
target: "openclaw-gateway",
624+
fixHint: "Run `openclaw doctor --fix` or `openclaw gateway install` to install it.",
625+
});
626+
});
627+
628+
it("skips daemon findings for remote gateway mode", async () => {
629+
await expect(
630+
collectGatewayDaemonFindings({ cfg: { gateway: { mode: "remote" } } }),
631+
).resolves.toEqual([]);
632+
633+
expect(mocks.readGatewayServiceState).not.toHaveBeenCalled();
634+
});
635+
});
636+
506637
describe("doctor provider catalog projection checks", () => {
507638
beforeEach(() => {
508639
mocks.resolvePluginProviders.mockReset().mockReturnValue([]);

src/flows/doctor-core-checks.runtime.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Doctor runtime checks inspect tool names, browser residue, and runtime state.
2+
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
23
import { TOOL_NAME_SEPARATOR } from "../agents/agent-bundle-mcp-names.js";
34
import {
45
type McpToolCatalogDiagnostic,
@@ -30,20 +31,32 @@ import {
3031
type RuntimeToolSchemaDiagnostic,
3132
} from "../agents/tool-schema-projection.js";
3233
import type { AnyAgentTool } from "../agents/tools/common.js";
34+
import { probeGatewayStatus } from "../cli/daemon-cli/probe.js";
3335
import { collectUnavailableAgentSkills } from "../commands/doctor-skills-core.js";
36+
import { gatewayProbeResultSawGateway } from "../commands/gateway-health-auth-diagnostic.js";
3437
import type { OpenClawConfig } from "../config/types.openclaw.js";
38+
import {
39+
getSystemdCgroupHygieneSummary,
40+
type GatewayServiceRuntime,
41+
} from "../daemon/service-runtime.js";
42+
import { resolveGatewayService, readGatewayServiceState } from "../daemon/service.js";
43+
import { buildGatewayProbeConnectionDetails } from "../gateway/call.js";
3544
import { formatErrorMessage } from "../infra/errors.js";
3645
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
3746
import { getPluginToolMeta, setPluginToolMeta } from "../plugins/tools.js";
3847
import type { ProviderCatalogOrder, ProviderPlugin } from "../plugins/types.js";
3948
import { normalizeAgentId } from "../routing/session-key.js";
4049
import { buildWorkspaceSkillStatus, type SkillStatusEntry } from "../skills/discovery/status.js";
41-
import type { HealthFinding } from "./health-checks.js";
50+
import type { HealthCheckContext, HealthFinding } from "./health-checks.js";
4251

4352
type BundleMcpToolRuntime = Awaited<ReturnType<typeof createBundleMcpToolRuntime>>;
4453
const PROVIDER_CATALOG_ORDERS = ["simple", "profile", "paired", "late"] as const;
4554
const PROVIDER_CATALOG_ORDER_SET = new Set<ProviderCatalogOrder>(PROVIDER_CATALOG_ORDERS);
4655

56+
function formatGatewayHealthTarget(url: string): string {
57+
return redactSensitiveUrlLikeString(url);
58+
}
59+
4760
export function detectUnavailableSkills(cfg: OpenClawConfig): SkillStatusEntry[] {
4861
const agentId = resolveDefaultAgentId(cfg);
4962
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
@@ -54,6 +67,136 @@ export function detectUnavailableSkills(cfg: OpenClawConfig): SkillStatusEntry[]
5467
return collectUnavailableAgentSkills(report);
5568
}
5669

70+
export async function collectGatewayHealthFindings(
71+
ctx: Pick<HealthCheckContext, "cfg" | "configPath">,
72+
): Promise<readonly HealthFinding[]> {
73+
let probeDetails: Awaited<ReturnType<typeof buildGatewayProbeConnectionDetails>>;
74+
try {
75+
probeDetails = await buildGatewayProbeConnectionDetails({
76+
config: ctx.cfg,
77+
...(ctx.configPath ? { configPath: ctx.configPath } : {}),
78+
});
79+
} catch (error) {
80+
return [
81+
{
82+
checkId: "core/doctor/gateway-health",
83+
severity: "warning",
84+
message: `Gateway health probe could not be prepared: ${formatErrorMessage(error)}`,
85+
path: ctx.cfg.gateway?.mode === "remote" ? "gateway.remote.url" : "gateway",
86+
fixHint:
87+
"Fix Gateway connection configuration, then rerun `openclaw doctor --lint --only core/doctor/gateway-health`.",
88+
},
89+
];
90+
}
91+
92+
const probe = await probeGatewayStatus({
93+
url: probeDetails.url,
94+
timeoutMs: 3000,
95+
tlsFingerprint: probeDetails.tlsFingerprint,
96+
preauthHandshakeTimeoutMs: probeDetails.preauthHandshakeTimeoutMs,
97+
config: ctx.cfg,
98+
json: true,
99+
});
100+
if (gatewayProbeResultSawGateway(probe)) {
101+
return [];
102+
}
103+
const mode = ctx.cfg.gateway?.mode === "remote" ? "remote" : "local";
104+
return [
105+
{
106+
checkId: "core/doctor/gateway-health",
107+
severity: "warning",
108+
message: `Gateway is not reachable: ${probe.error ?? "status probe failed"}`,
109+
path: mode === "remote" ? "gateway.remote.url" : "gateway.mode",
110+
target: formatGatewayHealthTarget(probeDetails.url),
111+
fixHint:
112+
mode === "remote"
113+
? "Verify the remote Gateway URL, network path, TLS settings, and credentials."
114+
: "Start the Gateway service or run `openclaw doctor --fix` for service repair prompts.",
115+
},
116+
];
117+
}
118+
119+
function gatewayRuntimeStatus(runtime: GatewayServiceRuntime | undefined): string | undefined {
120+
return runtime?.status ?? runtime?.state ?? runtime?.subState;
121+
}
122+
123+
export async function collectGatewayDaemonFindings(
124+
ctx: Pick<HealthCheckContext, "cfg">,
125+
): Promise<readonly HealthFinding[]> {
126+
if (ctx.cfg.gateway?.mode === "remote") {
127+
return [];
128+
}
129+
const service = resolveGatewayService();
130+
const state = await readGatewayServiceState(service, { env: process.env });
131+
const findings: HealthFinding[] = [];
132+
if (!state.installed) {
133+
findings.push({
134+
checkId: "core/doctor/gateway-daemon",
135+
severity: "warning",
136+
message: "Gateway service is not installed.",
137+
path: "gateway.mode",
138+
target: service.label,
139+
fixHint: "Run `openclaw doctor --fix` or `openclaw gateway install` to install it.",
140+
});
141+
return findings;
142+
}
143+
if (!state.loaded) {
144+
findings.push({
145+
checkId: "core/doctor/gateway-daemon",
146+
severity: "warning",
147+
message: "Gateway service is installed but not loaded.",
148+
path: state.command?.sourcePath,
149+
target: service.label,
150+
fixHint: "Run `openclaw doctor --fix` or `openclaw gateway start` to load it.",
151+
});
152+
}
153+
const status = gatewayRuntimeStatus(state.runtime);
154+
if (state.loaded && !state.running) {
155+
findings.push({
156+
checkId: "core/doctor/gateway-daemon",
157+
severity: "warning",
158+
message: status
159+
? `Gateway service runtime is ${status}, not running.`
160+
: "Gateway service is loaded but runtime status could not confirm it is running.",
161+
path: state.command?.sourcePath,
162+
target: service.label,
163+
fixHint: "Run `openclaw gateway status --deep` or `openclaw doctor --fix` for repair hints.",
164+
});
165+
}
166+
if (state.runtime?.missingGuiSession) {
167+
findings.push({
168+
checkId: "core/doctor/gateway-daemon",
169+
severity: "warning",
170+
message: "Gateway service cannot attach to the user GUI session.",
171+
path: state.command?.sourcePath,
172+
target: service.label,
173+
fixHint: state.runtime.detail ?? "Log into a GUI session, then rerun doctor.",
174+
});
175+
}
176+
if (state.runtime?.missingSupervision || state.runtime?.missingUnit) {
177+
findings.push({
178+
checkId: "core/doctor/gateway-daemon",
179+
severity: "warning",
180+
message: "Gateway service supervision metadata is missing.",
181+
path: state.command?.sourcePath,
182+
target: service.label,
183+
fixHint: state.runtime.detail ?? "Reinstall or reload the Gateway service.",
184+
});
185+
}
186+
const hygiene = getSystemdCgroupHygieneSummary(state.runtime?.systemd);
187+
if (hygiene) {
188+
findings.push({
189+
checkId: "core/doctor/gateway-daemon",
190+
severity: "warning",
191+
message: `Gateway systemd service has risky ${hygiene}.`,
192+
path: state.command?.sourcePath,
193+
target: service.label,
194+
fixHint: "Repair the systemd unit so stale child processes are cleaned up reliably.",
195+
});
196+
}
197+
return findings;
198+
}
199+
57200
function providerCatalogPath(pluginId: string | undefined): string | undefined {
58201
return pluginId ? `plugins.entries.${pluginId}` : undefined;
59202
}

0 commit comments

Comments
 (0)