fix(doctor): skip host services in externally managed containers (#125796)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
hongran997
2026-08-25 16:21:15 +08:00
committed by GitHub
parent 5a35bd0885
commit cd3a87f79f
14 changed files with 275 additions and 105 deletions
@@ -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);
+68 -77
View File
@@ -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({
+5 -2
View File
@@ -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, {
+2 -13
View File
@@ -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
View File
@@ -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", () => {
+2 -1
View File
@@ -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) =>
+8 -2
View File
@@ -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");
+6 -8
View File
@@ -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 {