diff --git a/scripts/e2e/system-agent-first-run-docker.sh b/scripts/e2e/system-agent-first-run-docker.sh index 28e32f764ba9..24618240fd9b 100644 --- a/scripts/e2e/system-agent-first-run-docker.sh +++ b/scripts/e2e/system-agent-first-run-docker.sh @@ -24,6 +24,7 @@ set +e docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ + -e "OPENCLAW_SUPERVISOR_MODE=external" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail source scripts/lib/openclaw-e2e-instance.sh diff --git a/src/system-agent/setup-apply.test.ts b/src/system-agent/setup-apply.test.ts index a785e2486020..920278d9d594 100644 --- a/src/system-agent/setup-apply.test.ts +++ b/src/system-agent/setup-apply.test.ts @@ -1018,17 +1018,15 @@ describe("applySystemAgentSetup transaction boundaries", () => { expect(result.gateway).toEqual({ status: "failed", error: "service exploded" }); }); - it("preserves the service owner's failed install outcome after config commits", async () => { - mocks.ensureGatewayService.mockResolvedValueOnce({ - gateway: { status: "failed", error: "gateway install blocked" }, - containerWithoutUserSystemd: false, - }); - + it.each([ + { status: "failed", error: "gateway install blocked" } as const, + { status: "skipped", reason: "external" } as const, + ])("preserves the service owner's $status outcome after config commits", async (gateway) => { + mocks.ensureGatewayService.mockResolvedValueOnce({ gateway }); const result = await applySystemAgentSetup(baseParams({ surface: "cli" })); - - expect(result.workspaceReady).toBe(true); - expect(result.gateway).toEqual({ status: "failed", error: "gateway install blocked" }); - expect(result.lines).toContain("Gateway service: gateway install blocked"); + const marker = gateway.status === "failed" ? gateway.error : "SUPERVISOR_MODE=external"; + expect(result.gateway).toEqual(gateway); + expect(result.lines.join("\n")).toContain(marker); expect(mocks.waitForGatewayReachable).not.toHaveBeenCalled(); }); diff --git a/src/system-agent/setup-apply.ts b/src/system-agent/setup-apply.ts index 46b2c35e082c..a8772987ca46 100644 --- a/src/system-agent/setup-apply.ts +++ b/src/system-agent/setup-apply.ts @@ -14,6 +14,7 @@ import { applyMergePatch } from "../config/merge-patch.js"; import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { formatExternalSupervisorActionRequired } from "../infra/gateway-supervision.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -692,6 +693,8 @@ export async function applySystemAgentSetup( gateway = { status: "failed", error: `Gateway is not reachable yet (${detail}).` }; lines.push(`Gateway: not reachable yet (${detail}) — say \`gateway status\` to check`); } + } else if (gateway.reason === "external") { + lines.push(`Gateway: ${formatExternalSupervisorActionRequired("start the gateway")}`); } else { lines.push( "Gateway: service install skipped — say `start gateway` when you want it running.", diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index f36331cab094..3b8a224f74cd 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -8,6 +8,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { GatewayTlsConfig } from "../config/types.gateway.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; +import { withEnvAsync } from "../test-utils/env.js"; type DefaultModelAuthStatus = ReturnType; type DefaultModelCatalogFacts = ReturnType< @@ -1318,6 +1319,75 @@ describe("finalizeSetupWizard", () => { expectNoteContains(prompter, "service install exploded", "Gateway"); }); + it("recognizes external supervision before probing Linux systemd", async () => { + await withPlatform("linux", async () => { + await withEnvAsync({ OPENCLAW_SUPERVISOR_MODE: "external" }, async () => { + isSystemdUserServiceAvailable.mockResolvedValue(false); + isContainerEnvironment.mockReturnValue(true); + const prompter = createLaterPrompter(); + + const result = await ensureGatewayServiceForOnboarding({ + flow: "quickstart", + opts: {}, + nextConfig: {}, + settings: { port: 18789 }, + prompter, + runtime: createRuntime(), + }); + + expect(result).toEqual({ + gateway: { status: "skipped", reason: "external" }, + containerWithoutUserSystemd: false, + }); + expect(isSystemdUserServiceAvailable).not.toHaveBeenCalled(); + expect(isContainerEnvironment).not.toHaveBeenCalled(); + expectNoteContains( + prompter, + "OpenClaw gateway lifecycle is managed by an external supervisor", + "Gateway", + ); + expectNoteNotContains(prompter, "Systemd user services are not available"); + expect(gatewayServiceInstall).not.toHaveBeenCalled(); + }); + }); + }); + + it("preserves external supervision through unreachable container recovery", async () => { + await withPlatform("linux", async () => { + await withEnvAsync({ OPENCLAW_SUPERVISOR_MODE: "external" }, async () => { + isSystemdUserServiceAvailable.mockResolvedValue(false); + isContainerEnvironment.mockReturnValue(true); + waitForGatewayReachable.mockResolvedValue({ + ok: false, + detail: "external gateway is offline", + }); + probeGatewayReachable.mockResolvedValue({ + ok: false, + detail: "external gateway is offline", + }); + const prompter = createLaterPrompter(); + const args = createAdvancedFinalizeArgs({ prompter }); + + await finalizeSetupWizard({ + ...args, + opts: { ...args.opts, skipHealth: false, skipUi: false }, + }); + + expect(isSystemdUserServiceAvailable).not.toHaveBeenCalled(); + expect(isContainerEnvironment).not.toHaveBeenCalled(); + expect(startGatewayServer).not.toHaveBeenCalled(); + expectNoteContains(prompter, "Use that supervisor to start the gateway.", "Gateway"); + expectNoteNotContains(prompter, "openclaw gateway run"); + expectNoteNotContains(prompter, "openclaw onboard --install-daemon"); + expect(prompter.outro).toHaveBeenCalledWith( + "Gateway not detected yet. OpenClaw gateway lifecycle is managed by an external " + + "supervisor (OPENCLAW_SUPERVISOR_MODE=external). Use that supervisor to start the " + + "gateway.", + ); + }); + }); + }); + it("installs a missing gateway service when onboarding resumes before installation", async () => { startGatewayService.mockResolvedValueOnce({ outcome: "missing-install", diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index 99a273e0589a..2279c1fccc91 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -163,6 +163,37 @@ export type GatewayServiceSetupOutcome = | { status: "skipped"; reason: "explicit" | "systemd-unavailable" | "external" } | { status: "failed"; error: string }; +function buildGatewayRecoveryProjection(gateway: GatewayServiceSetupOutcome): { + detail: string; + summary: string; +} { + const startGuidance = + gateway.status === "skipped" && gateway.reason === "external" + ? formatExternalSupervisorActionRequired("start the gateway") + : t("wizard.finalize.startGatewayNow", { + command: formatCliCommand("openclaw gateway run"), + }); + const notDetected = t("wizard.finalize.gatewayNotDetected"); + const summary = [notDetected, startGuidance].join(" "); + if (gateway.status === "skipped" && gateway.reason === "external") { + return { detail: [notDetected, startGuidance].join("\n"), summary }; + } + return { + detail: [ + notDetected, + t("wizard.finalize.noBackgroundGatewayExpected"), + startGuidance, + t("wizard.finalize.rerunInstallDaemon", { + command: formatCliCommand("openclaw onboard --install-daemon"), + }), + t("wizard.finalize.skipHealthNextTime", { + command: formatCliCommand("openclaw onboard --skip-health"), + }), + ].join("\n"), + summary, + }; +} + /** * Ensure the gateway service matches the onboarding decision: prompt/decide * whether to install the daemon, then install/restart/reinstall it. Shared by @@ -197,6 +228,17 @@ export async function ensureGatewayServiceForOnboarding(params: { } }; + if (isGatewayExternallySupervised()) { + await prompter.note( + formatExternalSupervisorActionRequired("manage the gateway service"), + "Gateway", + ); + return { + gateway: { status: "skipped", reason: "external" }, + containerWithoutUserSystemd: false, + }; + } + const systemdAvailable = process.platform === "linux" ? await isSystemdUserServiceAvailable() : true; const linuxWithoutUserSystemd = process.platform === "linux" && !systemdAvailable; @@ -259,14 +301,6 @@ export async function ensureGatewayServiceForOnboarding(params: { }; } - if (isGatewayExternallySupervised()) { - await prompter.note( - formatExternalSupervisorActionRequired("manage the gateway service"), - "Gateway", - ); - return { gateway: { status: "skipped", reason: "external" }, containerWithoutUserSystemd }; - } - let gateway: GatewayServiceSetupOutcome = { status: "ready", action: "reused" }; if (installDaemon) { const daemonRuntime = @@ -458,6 +492,7 @@ export async function finalizeSetupWizard( prompter, runtime, }); + const gatewayRecovery = buildGatewayRecoveryProjection(gateway); if (gateway.status === "failed") { gatewayProbe = { ok: false, detail: gateway.error }; } @@ -565,22 +600,7 @@ export async function finalizeSetupWizard( t("wizard.finalize.healthCheckHelp"), ); } else { - await prompter.note( - [ - t("wizard.finalize.gatewayNotDetected"), - t("wizard.finalize.noBackgroundGatewayExpected"), - t("wizard.finalize.startGatewayNow", { - command: formatCliCommand("openclaw gateway run"), - }), - t("wizard.finalize.rerunInstallDaemon", { - command: formatCliCommand("openclaw onboard --install-daemon"), - }), - t("wizard.finalize.skipHealthNextTime", { - command: formatCliCommand("openclaw onboard --skip-health"), - }), - ].join("\n"), - "Gateway", - ); + await prompter.note(gatewayRecovery.detail, "Gateway"); } } @@ -946,12 +966,7 @@ export async function finalizeSetupWizard( }), ].join(" ") : t("wizard.guided.complete") - : [ - t("wizard.finalize.gatewayNotDetected"), - t("wizard.finalize.startGatewayNow", { - command: formatCliCommand("openclaw gateway run"), - }), - ].join(" "), + : gatewayRecovery.summary, ); if (shouldLaunchTui) { diff --git a/test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts b/test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts index 7fef2298ab4d..6a8d91a16a1d 100644 --- a/test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts +++ b/test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts @@ -304,6 +304,32 @@ async function main() { result.code === 0 && output.includes(command.expectOutput), `OpenClaw first-run command ${command.id} did not apply: ${output}`, ); + if (command.id === "setup") { + assert(result.code === 0, `OpenClaw setup exited with ${result.code}: ${output}`); + assert( + output.includes("[openclaw] done: openclaw.setup"), + `OpenClaw setup did not report completion: ${output}`, + ); + assert( + output.includes( + "Gateway: OpenClaw gateway lifecycle is managed by an external supervisor " + + "(OPENCLAW_SUPERVISOR_MODE=external). Use that supervisor to start the gateway.", + ), + `OpenClaw setup did not report the externally supervised gateway: ${output}`, + ); + assert( + !output.includes("Systemd user services are not available"), + `OpenClaw setup probed systemd before honoring external supervision: ${output}`, + ); + assert( + !output.includes("Gateway service install failed"), + `OpenClaw setup attempted and failed gateway service installation: ${output}`, + ); + assert( + !output.includes("service management skipped: non-default state dir or config path"), + `OpenClaw setup used the non-default-path service-management skip: ${output}`, + ); + } if (command.planner) { assert( output.includes(`[openclaw] planner: ${spec.model}`) && diff --git a/test/scripts/docker-e2e-system-agent.test.ts b/test/scripts/docker-e2e-system-agent.test.ts index 9b166f22709c..8395378a44f1 100644 --- a/test/scripts/docker-e2e-system-agent.test.ts +++ b/test/scripts/docker-e2e-system-agent.test.ts @@ -8,9 +8,11 @@ function readScript(pathname: string): string { describe("OpenClaw Docker E2E scripts", () => { it("keeps first-run checks wired to packaged CLI and OpenClaw behavior", () => { + const shell = readScript("scripts/e2e/system-agent-first-run-docker.sh"); const source = readScript("test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts"); const spec = readScript("scripts/e2e/system-agent-first-run-spec.json"); + expect(shell).toContain('-e "OPENCLAW_SUPERVISOR_MODE=external"'); expect(source).toContain("../../../../dist/cli/run-main.js"); expect(source).toContain("../../../../dist/system-agent/setup-inference.js"); expect(source).toContain("shouldStartOnboardingForFreshInstall"); @@ -32,6 +34,10 @@ describe("OpenClaw Docker E2E scripts", () => { expect(source).toContain("expected one fuzzy setup planner prompt"); expect(source).toContain("OpenClaw did not enable Discord"); expect(source).toContain("OpenClaw did not write Discord token SecretRef"); + expect(source).toContain( + "(OPENCLAW_SUPERVISOR_MODE=external). Use that supervisor to start the gateway.", + ); + expect(source).toContain("OpenClaw setup probed systemd before honoring external supervision"); expect(source).toContain("OpenClaw first-run Docker E2E passed"); expect(spec).toContain('"auditOperations"'); expect(spec).toContain('"openclaw.setup"');