mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(doctor): skip host services in externally managed containers (#125796)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -38,6 +38,7 @@ const buildGatewayRuntimeHints = vi.hoisted(() => vi.fn((): string[] => []));
|
||||
const formatGatewayRuntimeSummary = vi.hoisted(() => vi.fn((): string | null => null));
|
||||
const renderSystemdUnavailableHints = vi.hoisted(() => vi.fn((): string[] => []));
|
||||
const isDefaultInstallIdentity = vi.hoisted(() => vi.fn(() => true));
|
||||
const isContainerEnvironment = vi.hoisted(() => vi.fn(() => false));
|
||||
const resolveGatewayBindHost = vi.hoisted(() => vi.fn(async () => "127.0.0.1"));
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
@@ -101,6 +102,8 @@ vi.mock("../infra/ports-inspect.js", () => ({
|
||||
inspectPortUsage,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/container-environment.js", () => ({ isContainerEnvironment }));
|
||||
|
||||
vi.mock("../infra/ports-format.js", () => ({
|
||||
formatPortDiagnostics,
|
||||
isExpectedGatewayListeners,
|
||||
@@ -173,6 +176,7 @@ describe("maybeRepairGatewayDaemon", () => {
|
||||
service.readCommand.mockResolvedValue(null);
|
||||
service.restart.mockResolvedValue({ outcome: "completed" });
|
||||
isDefaultInstallIdentity.mockReturnValue(true);
|
||||
isContainerEnvironment.mockReturnValue(false);
|
||||
readGatewayRestartHandoffSync.mockReturnValue(null);
|
||||
findSystemGatewayServices.mockResolvedValue([]);
|
||||
resolveGatewayBindHost.mockResolvedValue("127.0.0.1");
|
||||
@@ -336,6 +340,44 @@ describe("maybeRepairGatewayDaemon", () => {
|
||||
expect(service.readRuntime).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ environment: "detected container", detected: true, kubernetes: false },
|
||||
{ environment: "Kubernetes pod without container markers", detected: false, kubernetes: true },
|
||||
])(
|
||||
"keeps port diagnostics but never probes host services in a $environment",
|
||||
async (scenario) => {
|
||||
setPlatform("linux");
|
||||
isContainerEnvironment.mockReturnValue(scenario.detected);
|
||||
inspectPortUsage.mockResolvedValueOnce({
|
||||
port: 18789,
|
||||
status: "busy",
|
||||
listeners: [{ pid: 1234, command: "other-process" }],
|
||||
hints: [],
|
||||
});
|
||||
|
||||
await withEnvAsync(
|
||||
{
|
||||
KUBERNETES_SERVICE_HOST: scenario.kubernetes ? "10.96.0.1" : undefined,
|
||||
KUBERNETES_SERVICE_PORT: scenario.kubernetes ? "443" : undefined,
|
||||
},
|
||||
runNonInteractiveRepair,
|
||||
);
|
||||
|
||||
expect(inspectPortUsage).toHaveBeenCalledOnce();
|
||||
expect(note).toHaveBeenCalledWith("Port 18789 is already in use.", "Gateway port");
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"Container lifecycle is externally managed; skipping host service installation.",
|
||||
"Gateway",
|
||||
);
|
||||
expect(service.isLoaded).not.toHaveBeenCalled();
|
||||
expect(service.readRuntime).not.toHaveBeenCalled();
|
||||
expect(service.readCommand).not.toHaveBeenCalled();
|
||||
expect(service.install).not.toHaveBeenCalled();
|
||||
expect(service.restart).not.toHaveBeenCalled();
|
||||
expect(findSystemGatewayServices).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("reports recent restart handoffs during deep doctor", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(40_000);
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js";
|
||||
import { classifySystemdUnavailableDetail } from "../daemon/systemd-unavailable.js";
|
||||
import { resolveGatewayBindHost, resolveGatewayRequiredListenHosts } from "../gateway/net.js";
|
||||
import { NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON } from "../infra/gateway-supervision.js";
|
||||
import {
|
||||
isGatewayHostServiceEnvironment,
|
||||
NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON,
|
||||
} from "../infra/gateway-supervision.js";
|
||||
import { formatPortDiagnostics, isExpectedGatewayListeners } from "../infra/ports-format.js";
|
||||
import { inspectPortConnections, inspectPortUsage } from "../infra/ports-inspect.js";
|
||||
import type { PortConnection } from "../infra/ports-types.js";
|
||||
@@ -67,20 +70,11 @@ function noteGatewayRuntime(
|
||||
env: Record<string, string | undefined>,
|
||||
): void {
|
||||
const summary = formatGatewayRuntimeSummary(serviceRuntime);
|
||||
const hints = buildGatewayRuntimeHints(serviceRuntime, {
|
||||
platform: process.platform,
|
||||
env,
|
||||
});
|
||||
if (!summary && hints.length === 0) {
|
||||
return;
|
||||
const hints = buildGatewayRuntimeHints(serviceRuntime, { platform: process.platform, env });
|
||||
const lines = summary ? [`Runtime: ${summary}`, ...hints] : hints;
|
||||
if (lines.length > 0) {
|
||||
note(lines.join("\n"), "Gateway");
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (summary) {
|
||||
lines.push(`Runtime: ${summary}`);
|
||||
}
|
||||
lines.push(...hints);
|
||||
note(lines.join("\n"), "Gateway");
|
||||
}
|
||||
|
||||
async function maybeRepairLaunchAgentBootstrap(params: {
|
||||
@@ -90,17 +84,11 @@ async function maybeRepairLaunchAgentBootstrap(params: {
|
||||
prompter: DoctorPrompter;
|
||||
serviceRepairExternal: boolean;
|
||||
}): Promise<LaunchAgentBootstrapDoctorOutcome> {
|
||||
if (process.platform !== "darwin") {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const plistExists = await launchAgentPlistExists(params.env);
|
||||
if (!plistExists) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const loaded = await isLaunchAgentLoaded({ env: params.env });
|
||||
if (loaded) {
|
||||
if (
|
||||
process.platform !== "darwin" ||
|
||||
!(await launchAgentPlistExists(params.env)) ||
|
||||
(await isLaunchAgentLoaded({ env: params.env }))
|
||||
) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
@@ -110,11 +98,12 @@ async function maybeRepairLaunchAgentBootstrap(params: {
|
||||
return { status: "not-loaded" };
|
||||
}
|
||||
|
||||
const shouldFix = await confirmDoctorServiceRepair(params.prompter, {
|
||||
message: `Repair ${params.title} LaunchAgent bootstrap now?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!shouldFix) {
|
||||
if (
|
||||
!(await confirmDoctorServiceRepair(params.prompter, {
|
||||
message: `Repair ${params.title} LaunchAgent bootstrap now?`,
|
||||
initialValue: true,
|
||||
}))
|
||||
) {
|
||||
return { status: "not-loaded" };
|
||||
}
|
||||
|
||||
@@ -136,8 +125,7 @@ async function maybeRepairLaunchAgentBootstrap(params: {
|
||||
return { status: "not-loaded" };
|
||||
}
|
||||
|
||||
const verified = await isLaunchAgentLoaded({ env: params.env });
|
||||
if (!verified) {
|
||||
if (!(await isLaunchAgentLoaded({ env: params.env }))) {
|
||||
params.runtime.error(`${params.title} LaunchAgent still not loaded after repair.`);
|
||||
return { status: "not-loaded" };
|
||||
}
|
||||
@@ -172,33 +160,48 @@ function renderEstablishedGatewayConnections(connections: PortConnection[]): str
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function maybeReportEstablishedGatewayClients(params: {
|
||||
cfg: OpenClawConfig;
|
||||
deep: boolean;
|
||||
port?: number;
|
||||
}): Promise<void> {
|
||||
if (!params.deep || params.cfg.gateway?.mode === "remote") {
|
||||
async function maybeReportEstablishedGatewayClients(
|
||||
cfg: OpenClawConfig,
|
||||
deep: boolean,
|
||||
port?: number,
|
||||
): Promise<void> {
|
||||
if (!deep || cfg.gateway?.mode === "remote") {
|
||||
return;
|
||||
}
|
||||
const port = params.port ?? resolveGatewayPort(params.cfg, process.env);
|
||||
const connections = await inspectPortConnections(port).catch(() => null);
|
||||
const establishedClients = connections?.connections.filter(
|
||||
(connection) => connection.direction !== "server",
|
||||
);
|
||||
if (establishedClients && establishedClients.length > 0) {
|
||||
note(renderEstablishedGatewayConnections(establishedClients), "Gateway clients");
|
||||
const targetPort = port ?? resolveGatewayPort(cfg, process.env);
|
||||
const connections = await inspectPortConnections(targetPort).catch(() => null);
|
||||
const clients = connections?.connections.filter(({ direction }) => direction !== "server");
|
||||
if (clients?.length) {
|
||||
note(renderEstablishedGatewayConnections(clients), "Gateway clients");
|
||||
}
|
||||
}
|
||||
|
||||
async function noteGatewayPortDiagnostics(cfg: OpenClawConfig, deep: boolean): Promise<boolean> {
|
||||
const port = resolveGatewayPort(cfg, process.env);
|
||||
const bindHost = await resolveGatewayBindHost(
|
||||
cfg.gateway?.bind ?? "loopback",
|
||||
cfg.gateway?.customBindHost,
|
||||
);
|
||||
const diagnostics = await inspectPortUsage(port, {
|
||||
probeHosts: resolveGatewayRequiredListenHosts(bindHost),
|
||||
});
|
||||
await maybeReportEstablishedGatewayClients(cfg, deep, port);
|
||||
const conflict =
|
||||
diagnostics.status === "busy" &&
|
||||
!isExpectedGatewayListeners(diagnostics.listeners, diagnostics.port);
|
||||
if (conflict) {
|
||||
note(formatPortDiagnostics(diagnostics).join("\n"), "Gateway port");
|
||||
}
|
||||
return conflict;
|
||||
}
|
||||
|
||||
async function noteGatewayServiceInspectionFailure(
|
||||
loadState: Extract<GatewayServiceLoadState, { status: "unknown" }>,
|
||||
): Promise<void> {
|
||||
const lines = [`Gateway service status could not be determined: ${loadState.detail}`];
|
||||
if (process.platform === "linux") {
|
||||
const kind = classifySystemdUnavailableDetail(loadState.detail);
|
||||
if (kind) {
|
||||
lines.push(...renderSystemdUnavailableHints({ wsl: await isWSL(), kind }));
|
||||
}
|
||||
const kind = process.platform === "linux" && classifySystemdUnavailableDetail(loadState.detail);
|
||||
if (kind) {
|
||||
lines.push(...renderSystemdUnavailableHints({ wsl: await isWSL(), kind }));
|
||||
}
|
||||
lines.push(`Run ${formatCliCommand("openclaw gateway status --deep")} and retry doctor.`);
|
||||
note(lines.join("\n"), "Gateway");
|
||||
@@ -224,25 +227,30 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
return;
|
||||
}
|
||||
if (params.healthOk) {
|
||||
await maybeReportEstablishedGatewayClients({
|
||||
cfg: params.cfg,
|
||||
deep: params.options.deep ?? false,
|
||||
});
|
||||
await maybeReportEstablishedGatewayClients(params.cfg, params.options.deep ?? false);
|
||||
return;
|
||||
}
|
||||
if (params.healthSkipped && params.cfg.gateway?.mode === "remote") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isGatewayHostServiceEnvironment()) {
|
||||
if (params.cfg.gateway?.mode !== "remote") {
|
||||
await noteGatewayPortDiagnostics(params.cfg, params.options.deep ?? false);
|
||||
}
|
||||
note(
|
||||
"Container lifecycle is externally managed; skipping host service installation.",
|
||||
"Gateway",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceRepairPolicy = resolveServiceRepairPolicy();
|
||||
const serviceRepairExternal = isServiceRepairExternallyManaged(serviceRepairPolicy);
|
||||
const service = resolveGatewayService();
|
||||
const restartGatewayService = async () => {
|
||||
try {
|
||||
return await service.restart({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
return await service.restart({ env: process.env, stdout: process.stdout });
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
note(`Gateway service restart failed: ${detail}`, "Gateway");
|
||||
@@ -317,25 +325,8 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
}
|
||||
|
||||
if (params.cfg.gateway?.mode !== "remote") {
|
||||
const port = resolveGatewayPort(params.cfg, process.env);
|
||||
const bindHost = await resolveGatewayBindHost(
|
||||
params.cfg.gateway?.bind ?? "loopback",
|
||||
params.cfg.gateway?.customBindHost,
|
||||
);
|
||||
const diagnostics = await inspectPortUsage(port, {
|
||||
probeHosts: resolveGatewayRequiredListenHosts(bindHost),
|
||||
});
|
||||
await maybeReportEstablishedGatewayClients({
|
||||
cfg: params.cfg,
|
||||
deep: params.options.deep ?? false,
|
||||
port,
|
||||
});
|
||||
if (
|
||||
diagnostics.status === "busy" &&
|
||||
!isExpectedGatewayListeners(diagnostics.listeners, diagnostics.port)
|
||||
) {
|
||||
note(formatPortDiagnostics(diagnostics).join("\n"), "Gateway port");
|
||||
} else if (loaded && serviceRuntime?.status === "running") {
|
||||
const conflict = await noteGatewayPortDiagnostics(params.cfg, params.options.deep ?? false);
|
||||
if (!conflict && loaded && serviceRuntime?.status === "running") {
|
||||
const lastError = await readLastGatewayErrorLine(process.env);
|
||||
if (lastError) {
|
||||
note(`Last gateway error: ${lastError}`, "Gateway");
|
||||
|
||||
@@ -41,6 +41,7 @@ const mocks = vi.hoisted(() => ({
|
||||
resolveGatewayPort: vi.fn(() => 18789),
|
||||
resolveIsNixMode: vi.fn(() => false),
|
||||
isDefaultInstallIdentity: vi.fn(() => true),
|
||||
isContainerEnvironment: vi.fn(() => false),
|
||||
findExtraGatewayServices: vi.fn().mockResolvedValue([]),
|
||||
renderGatewayServiceCleanupHints: vi.fn().mockReturnValue([]),
|
||||
needsNodeRuntimeMigration: vi.fn(() => false),
|
||||
@@ -126,6 +127,10 @@ vi.mock("../infra/windows-port-pids.js", () => ({
|
||||
readWindowsProcessArgsSync: mocks.readWindowsProcessArgsSync,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/container-environment.js", () => ({
|
||||
isContainerEnvironment: mocks.isContainerEnvironment,
|
||||
}));
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runExec: mocks.runExec,
|
||||
}));
|
||||
@@ -1673,6 +1678,7 @@ describe("maybeRepairGatewayServiceConfig", () => {
|
||||
describe("maybeScanExtraGatewayServices", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isContainerEnvironment.mockReturnValue(false);
|
||||
mocks.findExtraGatewayServices.mockResolvedValue([]);
|
||||
mocks.renderGatewayServiceCleanupHints.mockReturnValue([]);
|
||||
mocks.isSystemdUnitActive.mockResolvedValue(false);
|
||||
@@ -1774,6 +1780,15 @@ describe("maybeScanExtraGatewayServices", () => {
|
||||
expect(mocks.findExtraGatewayServices).toHaveBeenCalledWith(process.env, { deep: true });
|
||||
});
|
||||
|
||||
it("skips structured host-service discovery in externally managed containers", async () => {
|
||||
mocks.isContainerEnvironment.mockReturnValue(true);
|
||||
|
||||
await expect(detectExtraGatewayServiceIssues({ deep: true })).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.findExtraGatewayServices).not.toHaveBeenCalled();
|
||||
expect(mocks.isSystemdUnitActive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps intentional extra gateway services to informational structured findings", () => {
|
||||
expect(
|
||||
extraGatewayServiceToHealthFinding({
|
||||
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
} from "../daemon/systemd.js";
|
||||
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON } from "../infra/gateway-supervision.js";
|
||||
import {
|
||||
isGatewayHostServiceEnvironment,
|
||||
NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON,
|
||||
} from "../infra/gateway-supervision.js";
|
||||
import { readWindowsProcessArgsSync } from "../infra/windows-port-pids.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -361,7 +364,7 @@ async function filterInactiveExtraGatewayServices(
|
||||
export async function detectExtraGatewayServiceIssues(
|
||||
options: Pick<DoctorOptions, "deep"> = {},
|
||||
): Promise<readonly ExtraGatewayService[]> {
|
||||
if (!isDefaultInstallIdentity(process.env)) {
|
||||
if (!isDefaultInstallIdentity(process.env) || !isGatewayHostServiceEnvironment()) {
|
||||
return [];
|
||||
}
|
||||
const detectedExtraServices = await findExtraGatewayServices(process.env, {
|
||||
|
||||
@@ -12,14 +12,7 @@ export const EXTERNAL_SERVICE_REPAIR_NOTE =
|
||||
export function resolveServiceRepairPolicy(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ServiceRepairPolicy {
|
||||
const value = env[SERVICE_REPAIR_POLICY_ENV]?.trim().toLowerCase();
|
||||
switch (value) {
|
||||
case "auto":
|
||||
case "external":
|
||||
return value;
|
||||
default:
|
||||
return "auto";
|
||||
}
|
||||
return env[SERVICE_REPAIR_POLICY_ENV]?.trim().toLowerCase() === "external" ? "external" : "auto";
|
||||
}
|
||||
|
||||
/** Returns true when service repairs should only emit external-supervisor guidance. */
|
||||
@@ -35,9 +28,5 @@ export async function confirmDoctorServiceRepair(
|
||||
params: Parameters<DoctorPrompter["confirmRuntimeRepair"]>[0],
|
||||
policy: ServiceRepairPolicy = resolveServiceRepairPolicy(),
|
||||
): Promise<boolean> {
|
||||
if (isServiceRepairExternallyManaged(policy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await prompter.confirmRuntimeRepair(params);
|
||||
return !isServiceRepairExternallyManaged(policy) && (await prompter.confirmRuntimeRepair(params));
|
||||
}
|
||||
|
||||
@@ -60,6 +60,24 @@ describe("web_fetch proxy doctor diagnostic", () => {
|
||||
expect(diagnostic).toContain("Direct TLS connectivity to docs.openclaw.ai:443 succeeded");
|
||||
});
|
||||
|
||||
it("keeps Kubernetes process proxy diagnostics without inspecting a host service", async () => {
|
||||
const service = serviceWithEnv({ HTTPS_PROXY: "http://service-proxy.example:8080" });
|
||||
const diagnostic = await collectDiagnostic({
|
||||
cfg: {},
|
||||
env: {
|
||||
HTTPS_PROXY: "http://pod-proxy.example:8080",
|
||||
KUBERNETES_SERVICE_HOST: "10.96.0.1",
|
||||
KUBERNETES_SERVICE_PORT: "443",
|
||||
},
|
||||
service,
|
||||
probeDirectConnectivity: vi.fn(async () => "reachable" as const),
|
||||
});
|
||||
|
||||
expect(diagnostic).toContain("proxy environment detected in the doctor process: HTTPS_PROXY");
|
||||
expect(diagnostic).not.toContain("installed Gateway service");
|
||||
expect(service.readCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports both process and installed service proxy sources", async () => {
|
||||
const diagnostic = await collectDiagnostic({
|
||||
cfg: {},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveGatewayService, type GatewayService } from "../daemon/service.js";
|
||||
import { isGatewayHostServiceEnvironment } from "../infra/gateway-supervision.js";
|
||||
import { hasEnvHttpProxyConfigured } from "../infra/net/proxy-env.js";
|
||||
|
||||
const DIRECT_PROBE_HOST = "docs.openclaw.ai";
|
||||
@@ -53,6 +54,9 @@ async function resolveProxyEnvSources(params: {
|
||||
if (hasEnvHttpProxyConfigured("https", params.env)) {
|
||||
sources.push({ env: params.env, label: "doctor process" });
|
||||
}
|
||||
if (!isGatewayHostServiceEnvironment(params.env)) {
|
||||
return sources;
|
||||
}
|
||||
const command = await params.service.readCommand(params.env).catch(() => null);
|
||||
const serviceEnv = command?.environment;
|
||||
if (serviceEnv && hasEnvHttpProxyConfigured("https", serviceEnv)) {
|
||||
|
||||
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||
buildGatewayProbeConnectionDetails: vi.fn(),
|
||||
callGateway: vi.fn(),
|
||||
isGatewayCredentialsRequiredError: vi.fn(),
|
||||
isContainerEnvironment: vi.fn(() => false),
|
||||
readGatewayServiceState: vi.fn(),
|
||||
resolveGatewayService: vi.fn(() => ({ label: "openclaw-gateway" })),
|
||||
resolvePluginProvidersCore: vi.fn((): Array<Record<string, unknown>> => []),
|
||||
@@ -58,6 +59,10 @@ vi.mock("../daemon/service.js", () => ({
|
||||
resolveGatewayService: mocks.resolveGatewayService,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/container-environment.js", () => ({
|
||||
isContainerEnvironment: mocks.isContainerEnvironment,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
inspectProviderToolSchemasWithPlugin: () => [],
|
||||
normalizeProviderToolSchemasWithPlugin: mocks.normalizeProviderToolSchemasWithPlugin,
|
||||
@@ -558,6 +563,7 @@ describe("doctor runtime tool schema checks", () => {
|
||||
|
||||
describe("doctor gateway runtime checks", () => {
|
||||
beforeEach(() => {
|
||||
mocks.isContainerEnvironment.mockReset().mockReturnValue(false);
|
||||
mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({
|
||||
url: "http://127.0.0.1:5829",
|
||||
});
|
||||
@@ -822,6 +828,17 @@ describe("doctor gateway runtime checks", () => {
|
||||
|
||||
expect(mocks.readGatewayServiceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips host-service findings for an externally managed container gateway", async () => {
|
||||
mocks.isContainerEnvironment.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
collectGatewayDaemonFindings({ cfg: { gateway: { mode: "local" } } }),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.resolveGatewayService).not.toHaveBeenCalled();
|
||||
expect(mocks.readGatewayServiceState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor provider catalog projection checks", () => {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "../gateway/call.js";
|
||||
import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { isGatewayHostServiceEnvironment } from "../infra/gateway-supervision.js";
|
||||
import {
|
||||
formatLocalAudioSelection,
|
||||
inspectLocalAudioSelection,
|
||||
@@ -204,7 +205,7 @@ function gatewayRuntimeStatus(runtime: GatewayServiceRuntime | undefined): strin
|
||||
export async function collectGatewayDaemonFindings(
|
||||
ctx: Pick<HealthCheckContext, "cfg">,
|
||||
): Promise<readonly HealthFinding[]> {
|
||||
if (ctx.cfg.gateway?.mode === "remote") {
|
||||
if (ctx.cfg.gateway?.mode === "remote" || !isGatewayHostServiceEnvironment()) {
|
||||
return [];
|
||||
}
|
||||
const service = resolveGatewayService();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { isDefaultInstallIdentity } from "../config/paths.js";
|
||||
import { NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON } from "../infra/gateway-supervision.js";
|
||||
import {
|
||||
isGatewayHostServiceEnvironment,
|
||||
NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON,
|
||||
} from "../infra/gateway-supervision.js";
|
||||
import { runCoreContributionHealth } from "./doctor-health-contribution-core.js";
|
||||
import type { DoctorHealthFlowContext } from "./doctor-health-contribution-types.js";
|
||||
import {
|
||||
@@ -24,6 +27,9 @@ export async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Pr
|
||||
note(NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, "Gateway");
|
||||
return;
|
||||
}
|
||||
if (!isGatewayHostServiceEnvironment(ctx.env ?? process.env)) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
maybeRepairGatewayServiceConfig,
|
||||
maybeResolveDuelingSystemdGatewayScopes,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DoctorOptions } from "../commands/doctor-prompter.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isGatewayHostServiceEnvironment } from "../infra/gateway-supervision.js";
|
||||
import type { DoctorHealthFlowContext } from "./doctor-health-contribution-types.js";
|
||||
import { resolveDoctorWorkspaceSuggestionScopes } from "./doctor-workspace-suggestion-scopes.js";
|
||||
import type { HealthCheckContext, HealthFinding } from "./health-checks.js";
|
||||
@@ -85,7 +86,7 @@ export async function collectWorkspaceStatusPluginVersionDrift(params: {
|
||||
cfg: OpenClawConfig;
|
||||
options?: Pick<DoctorOptions, "allowExec" | "deep" | "nonInteractive">;
|
||||
}): Promise<PluginVersionDriftReport | undefined> {
|
||||
if (params.cfg.gateway?.mode === "remote") {
|
||||
if (params.cfg.gateway?.mode === "remote" || !isGatewayHostServiceEnvironment()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock("../secrets/target-registry-data.js", async (importOriginal) => {
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isDefaultInstallIdentity: vi.fn(() => true),
|
||||
isContainerEnvironment: vi.fn(() => false),
|
||||
maybeRunConfiguredPluginInstallReleaseStep: vi.fn(),
|
||||
registerBundledHealthChecks: vi.fn(),
|
||||
runDoctorHealthRepairs: vi.fn(),
|
||||
@@ -269,6 +270,10 @@ vi.mock("../commands/doctor-gateway-daemon-flow.js", () => ({
|
||||
maybeRepairGatewayDaemon: mocks.maybeRepairGatewayDaemon,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/container-environment.js", () => ({
|
||||
isContainerEnvironment: mocks.isContainerEnvironment,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/service.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../daemon/service.js")>();
|
||||
return {
|
||||
@@ -626,6 +631,7 @@ describe("doctor health contributions", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.isContainerEnvironment.mockReset().mockReturnValue(false);
|
||||
mocks.maybeRunConfiguredPluginInstallReleaseStep.mockReset();
|
||||
mocks.registerBundledHealthChecks.mockReset();
|
||||
mocks.runDoctorHealthRepairs.mockReset();
|
||||
@@ -676,6 +682,7 @@ describe("doctor health contributions", () => {
|
||||
mocks.maybeRepairGatewayServiceConfig.mockResolvedValue(undefined);
|
||||
mocks.maybeScanExtraGatewayServices.mockClear();
|
||||
mocks.maybeScanExtraGatewayServices.mockResolvedValue(undefined);
|
||||
mocks.maybeResolveDuelingSystemdGatewayScopes.mockClear();
|
||||
mocks.noteMacLaunchAgentOverrides.mockClear();
|
||||
mocks.noteMacLaunchctlGatewayEnvOverrides.mockClear();
|
||||
mocks.noteMacStaleOpenClawUpdateLaunchdJobs.mockClear();
|
||||
@@ -1731,6 +1738,35 @@ describe("doctor health contributions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps workspace diagnostics without probing host services in Kubernetes", async () => {
|
||||
vi.stubEnv("KUBERNETES_SERVICE_HOST", "10.96.0.1");
|
||||
vi.stubEnv("KUBERNETES_SERVICE_PORT", "443");
|
||||
const contribution = requireDoctorContribution("doctor:workspace-status");
|
||||
const cfg = { plugins: { entries: { codex: { enabled: true } } } };
|
||||
|
||||
await contribution.run(
|
||||
createDoctorHealthFlowContext({ cfg, options: { nonInteractive: true } }),
|
||||
);
|
||||
|
||||
expect(mocks.gatherDaemonStatus).not.toHaveBeenCalled();
|
||||
expect(mocks.noteWorkspaceStatus).toHaveBeenCalledWith(cfg, {
|
||||
pluginVersionDrift: undefined,
|
||||
});
|
||||
|
||||
const ctx = createDoctorLintContext({
|
||||
cfg,
|
||||
mode: "lint",
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
});
|
||||
const check = contribution.healthChecks[0] as HealthCheck;
|
||||
await runDoctorLintChecks(ctx, { checks: [check], onlyIds: ["core/doctor/workspace-status"] });
|
||||
|
||||
expect(mocks.gatherDaemonStatus).not.toHaveBeenCalled();
|
||||
expect(mocks.collectWorkspaceStatusHealthFindings).toHaveBeenCalledWith(cfg, {
|
||||
pluginVersionDrift: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets daemon status decide exec SecretRef probing from daemon config", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:workspace-status");
|
||||
const pluginVersionDrift = {
|
||||
@@ -2019,6 +2055,26 @@ describe("doctor health contributions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("silently skips the host-service contribution in an externally managed container", async () => {
|
||||
mocks.isContainerEnvironment.mockReturnValue(true);
|
||||
const contribution = requireDoctorContribution("doctor:gateway-services");
|
||||
const ctx = createDoctorHealthFlowContext({
|
||||
cfg: { gateway: { mode: "local" } },
|
||||
configResult: {},
|
||||
sourceConfigValid: true,
|
||||
prompter: buildDoctorPrompter(true),
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
options: {},
|
||||
});
|
||||
|
||||
await contribution.run(ctx);
|
||||
|
||||
expect(mocks.maybeScanExtraGatewayServices).not.toHaveBeenCalled();
|
||||
expect(mocks.maybeResolveDuelingSystemdGatewayScopes).not.toHaveBeenCalled();
|
||||
expect(mocks.maybeRepairGatewayServiceConfig).not.toHaveBeenCalled();
|
||||
expect(mocks.note).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hints how to enable authenticated GitHub project search", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:github-projects");
|
||||
const ctx = createDoctorHealthFlowContext({
|
||||
@@ -2460,6 +2516,29 @@ describe("doctor health contributions", () => {
|
||||
expect(mocks.readSystemdUserLingerStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never probes systemd linger when selected inside an externally managed container", async () => {
|
||||
mocks.isContainerEnvironment.mockReturnValue(true);
|
||||
const checks = await resolveDoctorContributionHealthChecks();
|
||||
const lingerCheck = checks.find((check) => check.id === "core/doctor/systemd-linger");
|
||||
expect(lingerCheck).toBeDefined();
|
||||
|
||||
await withProcessPlatform("linux", async () => {
|
||||
await expect(
|
||||
runDoctorLintChecks(
|
||||
{
|
||||
cfg: { gateway: { mode: "local" } },
|
||||
mode: "lint",
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
},
|
||||
{ checks: [lingerCheck!], onlyIds: ["core/doctor/systemd-linger"] },
|
||||
),
|
||||
).resolves.toMatchObject({ checksRun: 1, findings: [] });
|
||||
});
|
||||
|
||||
expect(mocks.gatewayServiceIsLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.readSystemdUserLingerStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports the Gateway service owner under sudo-to-root", async () => {
|
||||
mocks.resolveSystemdUserServiceAccount.mockReturnValue("debian");
|
||||
mocks.readSystemdUserLingerStatus.mockImplementation(async (params) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Doctor health contributions preserve the ordered interactive doctor flow while
|
||||
// exposing the same checks to structured lint and repair commands.
|
||||
import fs from "node:fs";
|
||||
import { isGatewayHostServiceEnvironment } from "../infra/gateway-supervision.js";
|
||||
import { scrubDoctorErrorMessage } from "./doctor-error-message.js";
|
||||
import { hasActiveGatewayExecCredential } from "./doctor-gateway-exec-credential.js";
|
||||
import {
|
||||
@@ -277,7 +278,8 @@ async function runSystemdLingerHealth(ctx: DoctorHealthFlowContext): Promise<voi
|
||||
if (
|
||||
ctx.options.nonInteractive === true ||
|
||||
process.platform !== "linux" ||
|
||||
resolveDoctorMode(ctx.cfg) !== "local"
|
||||
resolveDoctorMode(ctx.cfg) !== "local" ||
|
||||
!isGatewayHostServiceEnvironment(ctx.env ?? process.env)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -304,7 +306,11 @@ async function runSystemdLingerHealth(ctx: DoctorHealthFlowContext): Promise<voi
|
||||
async function detectSystemdLingerFindings(
|
||||
ctx: HealthCheckContext,
|
||||
): Promise<readonly HealthFinding[]> {
|
||||
if (process.platform !== "linux" || resolveDoctorMode(ctx.cfg) !== "local") {
|
||||
if (
|
||||
process.platform !== "linux" ||
|
||||
resolveDoctorMode(ctx.cfg) !== "local" ||
|
||||
!isGatewayHostServiceEnvironment(ctx.env ?? process.env)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const { readGatewayServiceState, resolveGatewayService } = await import("../daemon/service.js");
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
// Defines gateway lifecycle ownership shared by service, restart, and update paths.
|
||||
import { isDefaultInstallIdentity, resolveNativeServiceProfileConflict } from "../config/paths.js";
|
||||
import { resolveGatewayNativeServiceIdentityConflict } from "../daemon/constants.js";
|
||||
import { isContainerEnvironment } from "./container-environment.js";
|
||||
|
||||
const GATEWAY_SUPERVISOR_MODE_ENV = "OPENCLAW_SUPERVISOR_MODE";
|
||||
export const EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON = "external-supervisor-update-required";
|
||||
export const NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON =
|
||||
"service management skipped: non-default state dir or config path";
|
||||
|
||||
type GatewaySupervisorMode = "auto" | "external";
|
||||
|
||||
function resolveGatewaySupervisorMode(env: NodeJS.ProcessEnv = process.env): GatewaySupervisorMode {
|
||||
return env[GATEWAY_SUPERVISOR_MODE_ENV]?.trim().toLowerCase() === "external"
|
||||
? "external"
|
||||
: "auto";
|
||||
export function isGatewayExternallySupervised(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env[GATEWAY_SUPERVISOR_MODE_ENV]?.trim().toLowerCase() === "external";
|
||||
}
|
||||
|
||||
export function isGatewayExternallySupervised(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return resolveGatewaySupervisorMode(env) === "external";
|
||||
export function isGatewayHostServiceEnvironment(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const { KUBERNETES_SERVICE_HOST: host, KUBERNETES_SERVICE_PORT: port } = env;
|
||||
return !isContainerEnvironment() && !(host?.trim() && port?.trim());
|
||||
}
|
||||
|
||||
export function formatExternalSupervisorActionRequired(action: string): string {
|
||||
|
||||
Reference in New Issue
Block a user