mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 09:51:13 +00:00
feat: add external gateway supervision policy
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
40
src/infra/gateway-supervision.test.ts
Normal file
40
src/infra/gateway-supervision.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
40
src/infra/gateway-supervision.ts
Normal file
40
src/infra/gateway-supervision.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user