fix(onboarding): honor external gateway supervision (#119846)

Punchcard-Session: quiet-meadow-timber-mj
This commit is contained in:
Vincent Koc
2026-08-06 17:26:43 +08:00
committed by GitHub
parent 83d586083d
commit beda1e97c6
7 changed files with 159 additions and 40 deletions
@@ -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
+8 -10
View File
@@ -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();
});
+3
View File
@@ -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.",
+70
View File
@@ -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<typeof AuthChoiceModelCheck.resolveDefaultModelAuthStatus>;
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",
+45 -30
View File
@@ -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) {
@@ -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}`) &&
@@ -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"');