mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(onboarding): honor external gateway supervision (#119846)
Punchcard-Session: quiet-meadow-timber-mj
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"');
|
||||
|
||||
Reference in New Issue
Block a user