diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 47ef2546c6e6..022df8c678df 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -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); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 96525a7c52fb..f69c7a644f78 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -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, ): 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 { - 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 { - if (!params.deep || params.cfg.gateway?.mode === "remote") { +async function maybeReportEstablishedGatewayClients( + cfg: OpenClawConfig, + deep: boolean, + port?: number, +): Promise { + 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 { + 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, ): Promise { 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"); diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 4f908bc41e2f..d2fb7d79b38c 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -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({ diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 945c032e31d0..313cf908d812 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -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 = {}, ): Promise { - if (!isDefaultInstallIdentity(process.env)) { + if (!isDefaultInstallIdentity(process.env) || !isGatewayHostServiceEnvironment()) { return []; } const detectedExtraServices = await findExtraGatewayServices(process.env, { diff --git a/src/commands/doctor-service-repair-policy.ts b/src/commands/doctor-service-repair-policy.ts index 27b06c3ad26c..6ad3727c851a 100644 --- a/src/commands/doctor-service-repair-policy.ts +++ b/src/commands/doctor-service-repair-policy.ts @@ -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[0], policy: ServiceRepairPolicy = resolveServiceRepairPolicy(), ): Promise { - if (isServiceRepairExternallyManaged(policy)) { - return false; - } - - return await prompter.confirmRuntimeRepair(params); + return !isServiceRepairExternallyManaged(policy) && (await prompter.confirmRuntimeRepair(params)); } diff --git a/src/commands/doctor-web-fetch-proxy.test.ts b/src/commands/doctor-web-fetch-proxy.test.ts index eec2e9457b60..2d9350ccb0ab 100644 --- a/src/commands/doctor-web-fetch-proxy.test.ts +++ b/src/commands/doctor-web-fetch-proxy.test.ts @@ -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: {}, diff --git a/src/commands/doctor-web-fetch-proxy.ts b/src/commands/doctor-web-fetch-proxy.ts index 1634f79fd401..556130b0b74b 100644 --- a/src/commands/doctor-web-fetch-proxy.ts +++ b/src/commands/doctor-web-fetch-proxy.ts @@ -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)) { diff --git a/src/flows/doctor-core-checks.runtime.test.ts b/src/flows/doctor-core-checks.runtime.test.ts index 75f47ac55c17..6e2cf37f6d55 100644 --- a/src/flows/doctor-core-checks.runtime.test.ts +++ b/src/flows/doctor-core-checks.runtime.test.ts @@ -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> => []), @@ -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", () => { diff --git a/src/flows/doctor-core-checks.runtime.ts b/src/flows/doctor-core-checks.runtime.ts index d533fd5e7735..44a0921a629b 100644 --- a/src/flows/doctor-core-checks.runtime.ts +++ b/src/flows/doctor-core-checks.runtime.ts @@ -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, ): Promise { - if (ctx.cfg.gateway?.mode === "remote") { + if (ctx.cfg.gateway?.mode === "remote" || !isGatewayHostServiceEnvironment()) { return []; } const service = resolveGatewayService(); diff --git a/src/flows/doctor-health-contribution-runners.gateway.ts b/src/flows/doctor-health-contribution-runners.gateway.ts index 0f280f2e20c5..e890f157b9d5 100644 --- a/src/flows/doctor-health-contribution-runners.gateway.ts +++ b/src/flows/doctor-health-contribution-runners.gateway.ts @@ -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, diff --git a/src/flows/doctor-health-contribution-runners.workspace.ts b/src/flows/doctor-health-contribution-runners.workspace.ts index 6d871fbae61b..2a2658298a45 100644 --- a/src/flows/doctor-health-contribution-runners.workspace.ts +++ b/src/flows/doctor-health-contribution-runners.workspace.ts @@ -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; }): Promise { - if (params.cfg.gateway?.mode === "remote") { + if (params.cfg.gateway?.mode === "remote" || !isGatewayHostServiceEnvironment()) { return undefined; } try { diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 6b32bad51db4..d2708bdb4064 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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(); 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) => diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index c9dc6613f480..beaa2b568f2d 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -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 { - 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"); diff --git a/src/infra/gateway-supervision.ts b/src/infra/gateway-supervision.ts index 8f2e864e3cfc..670d98f60cd8 100644 --- a/src/infra/gateway-supervision.ts +++ b/src/infra/gateway-supervision.ts @@ -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 {