feat: add external gateway supervision policy

This commit is contained in:
Shakker
2026-07-16 13:00:56 +01:00
committed by Shakker
parent 46c3dba537
commit 244f4965dc
10 changed files with 232 additions and 4 deletions

View File

@@ -1086,6 +1086,23 @@ describe("update-cli", () => {
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
});
it("delegates mutating updates when an external supervisor owns gateway lifecycle", async () => {
await withEnvAsync({ OPENCLAW_SUPERVISOR_MODE: "external" }, async () => {
await updateCommand({ yes: true });
});
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(runtimeCapture.error).toHaveBeenCalledWith(
expect.stringContaining(
"Use the external supervisor's update workflow so it can stop the gateway",
),
);
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(readConfigFileSnapshot).not.toHaveBeenCalled();
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
});
it("logs friendly hint with manual refresh command when completion cache write times out", async () => {
const root = createCaseDir("openclaw-completion-timeout-msg");
pathExists.mockResolvedValue(true);

View File

@@ -21,6 +21,10 @@ import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint
import { disableCurrentOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
import { createLowDiskSpaceWarning } from "../../infra/disk-space.js";
import {
formatExternalSupervisorUpdateRequired,
isGatewayExternallySupervised,
} from "../../infra/gateway-supervision.js";
import {
markPackagePostInstallDoctorAdvisory,
runGlobalPackageUpdateSteps,
@@ -544,6 +548,11 @@ async function updateCommandInternal(
if (timeoutMs === null) {
return;
}
if (!postCoreUpdateResume && opts.dryRun !== true && isGatewayExternallySupervised()) {
defaultRuntime.error(formatExternalSupervisorUpdateRequired());
defaultRuntime.exit(1);
return;
}
if (opts.dryRun !== true) {
try {
assertConfigWriteAllowedInCurrentMode();

View File

@@ -95,6 +95,30 @@ describe("resolveGatewayService", () => {
}
});
it("guards every native service mutation when an external supervisor owns lifecycle", async () => {
setPlatform("darwin");
const service = resolveGatewayService();
const env = { OPENCLAW_SUPERVISOR_MODE: "external" };
const installArgs = {
env,
stdout: process.stdout,
programArguments: ["openclaw", "gateway", "run"],
};
const mutations = [
() => service.stage(installArgs),
() => service.install(installArgs),
() => service.uninstall({ env, stdout: process.stdout }),
() => service.stop({ env, stdout: process.stdout }),
() => service.restart({ env, stdout: process.stdout }),
];
for (const mutate of mutations) {
await expect(mutate()).rejects.toThrow(
"gateway lifecycle is managed by an external supervisor",
);
}
});
it("describes scheduled restart handoffs consistently", () => {
expect(describeGatewayServiceRestart("Gateway", { outcome: "scheduled" })).toEqual({
scheduled: true,

View File

@@ -3,6 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { assertGatewayServiceMutationAllowed } from "../infra/gateway-supervision.js";
import { parseTcpPort, parseTcpPortFromArgs } from "../infra/tcp-port.js";
import { VERSION } from "../version.js";
import { assertFutureConfigActionAllowed } from "./future-config-guard.js";
@@ -336,28 +337,46 @@ const GATEWAY_SERVICE_REGISTRY: Record<SupportedGatewayServicePlatform, GatewayS
},
};
function withFutureConfigGuard(service: GatewayService): GatewayService {
function assertGatewayServiceMutationOwnedByOpenClaw(
action: string,
env?: GatewayServiceEnv,
): void {
assertGatewayServiceMutationAllowed(action, process.env);
if (env && env !== process.env) {
assertGatewayServiceMutationAllowed(action, env);
}
}
function withGatewayServiceMutationGuards(service: GatewayService): GatewayService {
return {
...service,
stage: async (args) => {
// Service mutations rewrite durable launchd/systemd/schtasks files, so
// block them when config was produced by a newer OpenClaw.
assertGatewayServiceMutationOwnedByOpenClaw("rewrite the gateway service", args.env);
await assertFutureConfigActionAllowed("rewrite the gateway service");
return await service.stage(args);
},
install: async (args) => {
assertGatewayServiceMutationOwnedByOpenClaw(
"install or rewrite the gateway service",
args.env,
);
await assertFutureConfigActionAllowed("install or rewrite the gateway service");
return await service.install(args);
},
uninstall: async (args) => {
assertGatewayServiceMutationOwnedByOpenClaw("uninstall the gateway service", args.env);
await assertFutureConfigActionAllowed("uninstall the gateway service");
return await service.uninstall(args);
},
stop: async (args) => {
assertGatewayServiceMutationOwnedByOpenClaw("stop the gateway service", args.env);
await assertFutureConfigActionAllowed("stop the gateway service");
return await service.stop(args);
},
restart: async (args) => {
assertGatewayServiceMutationOwnedByOpenClaw("restart the gateway service", args.env);
await assertFutureConfigActionAllowed("restart the gateway service");
return await service.restart(args);
},
@@ -372,7 +391,7 @@ function isSupportedGatewayServicePlatform(
export function resolveGatewayService(): GatewayService {
if (isSupportedGatewayServicePlatform(process.platform)) {
return withFutureConfigGuard(GATEWAY_SERVICE_REGISTRY[process.platform]);
return withGatewayServiceMutationGuards(GATEWAY_SERVICE_REGISTRY[process.platform]);
}
return createUnsupportedGatewayService();
}

View File

@@ -827,6 +827,25 @@ describe("update.run restart scheduling", () => {
expect(payload?.result?.reason).toBe("restart-unavailable");
expect(payload?.result?.mode).toBe("npm");
});
it("delegates update.run without mutating or restarting under external supervision", async () => {
mockGlobalInstallSurface();
const payload = await withProcessEnv({ OPENCLAW_SUPERVISOR_MODE: "external" }, () =>
captureUpdateRunPayload(),
);
expect(runGatewayUpdateMock).not.toHaveBeenCalled();
expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled();
expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled();
expect(payload?.ok).toBe(false);
expect(payload?.restart).toBeNull();
expect(payload?.result).toMatchObject({
status: "skipped",
mode: "npm",
reason: "external-supervisor-update-required",
});
});
});
describe("update.run post-core plugin finalize", () => {

View File

@@ -12,6 +12,10 @@ import { readConfigFileSnapshot } from "../../config/config.js";
import { extractDeliveryInfo } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "../../daemon/constants.js";
import {
EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON,
isGatewayExternallySupervised,
} from "../../infra/gateway-supervision.js";
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
import { readPackageVersion } from "../../infra/package-json.js";
import { type RestartSentinelPayload, writeRestartSentinel } from "../../infra/restart-sentinel.js";
@@ -197,7 +201,20 @@ export const updateHandlers: GatewayRequestHandlers = {
: false;
const requiresManagedServiceHandoff =
installSurface.kind === "global" || (installSurface.kind === "git" && supervisor !== null);
if (configChannel === "extended-stable" && installSurface.kind === "git") {
if (isGatewayExternallySupervised()) {
const beforeVersion = installSurface.root
? await readPackageVersion(installSurface.root)
: null;
result = {
status: "skipped",
mode: installSurface.mode,
...(installSurface.root ? { root: installSurface.root } : {}),
reason: EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON,
...(beforeVersion ? { before: { version: beforeVersion } } : {}),
steps: [],
durationMs: 0,
};
} else if (configChannel === "extended-stable" && installSurface.kind === "git") {
result = {
status: "error",
mode: "git",

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
assertGatewayServiceMutationAllowed,
formatExternalSupervisorUpdateRequired,
GATEWAY_SUPERVISOR_MODE_ENV,
isGatewayExternallySupervised,
resolveGatewaySupervisorMode,
} from "./gateway-supervision.js";
describe("gateway supervision", () => {
it.each([
{ value: undefined, expected: "auto" },
{ value: "", expected: "auto" },
{ value: "auto", expected: "auto" },
{ value: "invalid", expected: "auto" },
{ value: " EXTERNAL ", expected: "external" },
])("resolves $value as $expected", ({ value, expected }) => {
const env = { [GATEWAY_SUPERVISOR_MODE_ENV]: value };
expect(resolveGatewaySupervisorMode(env)).toBe(expected);
expect(isGatewayExternallySupervised(env)).toBe(expected === "external");
});
it("blocks native service mutation with actionable guidance", () => {
expect(() =>
assertGatewayServiceMutationAllowed("restart the gateway", {
[GATEWAY_SUPERVISOR_MODE_ENV]: "external",
}),
).toThrow(
"OpenClaw gateway lifecycle is managed by an external supervisor " +
"(OPENCLAW_SUPERVISOR_MODE=external). Use that supervisor to restart the gateway.",
);
});
it("explains why self-update must be delegated", () => {
expect(formatExternalSupervisorUpdateRequired()).toContain(
"stop the gateway, update and finalize the runtime, then restart it safely",
);
});
});

View File

@@ -0,0 +1,40 @@
// Defines gateway lifecycle ownership shared by service, restart, and update paths.
export const GATEWAY_SUPERVISOR_MODE_ENV = "OPENCLAW_SUPERVISOR_MODE";
export const EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON = "external-supervisor-update-required";
export type GatewaySupervisorMode = "auto" | "external";
export 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 resolveGatewaySupervisorMode(env) === "external";
}
export function formatExternalSupervisorActionRequired(action: string): string {
return [
`OpenClaw gateway lifecycle is managed by an external supervisor (${GATEWAY_SUPERVISOR_MODE_ENV}=external).`,
`Use that supervisor to ${action}.`,
].join(" ");
}
export function formatExternalSupervisorUpdateRequired(): string {
return [
`OpenClaw self-update is disabled while gateway lifecycle is managed by an external supervisor (${GATEWAY_SUPERVISOR_MODE_ENV}=external).`,
"Use the external supervisor's update workflow so it can stop the gateway, update and finalize the runtime, then restart it safely.",
].join(" ");
}
export function assertGatewayServiceMutationAllowed(
action: string,
env: NodeJS.ProcessEnv = process.env,
): void {
if (isGatewayExternallySupervised(env)) {
throw new Error(formatExternalSupervisorActionRequired(action));
}
}

View File

@@ -215,6 +215,7 @@ describe("update-startup", () => {
prefix: "openclaw-update-check-suite-",
env: {
OPENCLAW_NO_AUTO_UPDATE: undefined,
OPENCLAW_SUPERVISOR_MODE: undefined,
OPENCLAW_SERVICE_KIND: undefined,
OPENCLAW_SERVICE_MARKER: undefined,
OPENCLAW_GATEWAY_SERVICE_PID: undefined,
@@ -1004,6 +1005,28 @@ describe("update-startup", () => {
]);
});
it("delegates configured auto-updates to an external supervisor", async () => {
mockPackageUpdateStatus("beta", "2.0.0-beta.1");
process.env.OPENCLAW_SUPERVISOR_MODE = "external";
const log = { info: vi.fn() };
const runAutoUpdate = createAutoUpdateSuccessMock();
await runGatewayUpdateCheck({
cfg: createBetaAutoUpdateConfig(),
log,
isNixMode: false,
allowInTests: true,
runAutoUpdate,
});
expect(runAutoUpdate).not.toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith("auto-update delegated to external supervisor", {
version: "2.0.0-beta.1",
tag: "beta",
reason: "external-supervisor-update-required",
});
});
it("uses current runtime + entrypoint for default auto-update command execution", async () => {
mockPackageInstallStatus();
mockNpmChannelTag("beta", "2.0.0-beta.1");

View File

@@ -19,6 +19,10 @@ import {
} from "../state/openclaw-state-db.js";
import { VERSION } from "../version.js";
import { isTruthyEnvValue } from "./env.js";
import {
EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON,
isGatewayExternallySupervised,
} from "./gateway-supervision.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
@@ -422,6 +426,13 @@ async function runAutoUpdateCommand(params: {
restartDrainTimeoutMs: number | undefined;
root?: string;
}): Promise<AutoUpdateRunResult> {
if (isGatewayExternallySupervised()) {
return {
ok: false,
code: null,
reason: EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON,
};
}
const supervisor = detectRespawnSupervisor(process.env, process.platform, {
includeLinuxOpenClawGatewayServiceMarker: true,
});
@@ -537,8 +548,10 @@ export async function runGatewayUpdateCheck(params: {
normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL;
const auto = resolveAutoUpdatePolicy(params.cfg);
const autoDisabledByEnv = isTruthyEnvValue(process.env.OPENCLAW_NO_AUTO_UPDATE);
const autoDisabledByExternalSupervisor = isGatewayExternallySupervised();
const isAutoUpdateChannel = configuredChannel === "stable" || configuredChannel === "beta";
const shouldRunAutoUpdate = isAutoUpdateChannel && auto.enabled && !autoDisabledByEnv;
const shouldRunAutoUpdate =
isAutoUpdateChannel && auto.enabled && !autoDisabledByEnv && !autoDisabledByExternalSupervisor;
const shouldRunUpdateHints = params.cfg.update?.checkOnStart !== false;
if (!shouldRunUpdateHints && !shouldRunAutoUpdate) {
if (configuredChannel === "extended-stable") {
@@ -666,6 +679,13 @@ export async function runGatewayUpdateCheck(params: {
tag,
});
}
if (channel !== "extended-stable" && auto.enabled && autoDisabledByExternalSupervisor) {
params.log.info("auto-update delegated to external supervisor", {
version: resolved.version,
tag,
reason: EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON,
});
}
if (shouldRunAutoUpdate && (channel === "stable" || channel === "beta")) {
const runAuto = params.runAutoUpdate ?? runAutoUpdateCommand;