From 244f4965dc7bb83c59b66871442a9062700ddf43 Mon Sep 17 00:00:00 2001 From: Shakker Date: Thu, 16 Jul 2026 13:00:56 +0100 Subject: [PATCH] feat: add external gateway supervision policy --- src/cli/update-cli.test.ts | 17 ++++++++++ src/cli/update-cli/update-command.ts | 9 +++++ src/daemon/service.test.ts | 24 ++++++++++++++ src/daemon/service.ts | 23 +++++++++++-- src/gateway/server-methods/update.test.ts | 19 +++++++++++ src/gateway/server-methods/update.ts | 19 ++++++++++- src/infra/gateway-supervision.test.ts | 40 +++++++++++++++++++++++ src/infra/gateway-supervision.ts | 40 +++++++++++++++++++++++ src/infra/update-startup.test.ts | 23 +++++++++++++ src/infra/update-startup.ts | 22 ++++++++++++- 10 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 src/infra/gateway-supervision.test.ts create mode 100644 src/infra/gateway-supervision.ts diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index bb93a8c431c1..4697fce4cd89 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 1cab7ab999f3..11e5ccdd48f2 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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(); diff --git a/src/daemon/service.test.ts b/src/daemon/service.test.ts index 334e6b51ef19..1d596ddf323f 100644 --- a/src/daemon/service.test.ts +++ b/src/daemon/service.test.ts @@ -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, diff --git a/src/daemon/service.ts b/src/daemon/service.ts index bf84e1ea2b51..2fcf39807f99 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -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 { // 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(); } diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index 89ec8a5bd39b..2279db6c539c 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -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", () => { diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index cf46a527e80a..257455e43d6a 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -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", diff --git a/src/infra/gateway-supervision.test.ts b/src/infra/gateway-supervision.test.ts new file mode 100644 index 000000000000..78fe04ffba2d --- /dev/null +++ b/src/infra/gateway-supervision.test.ts @@ -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", + ); + }); +}); diff --git a/src/infra/gateway-supervision.ts b/src/infra/gateway-supervision.ts new file mode 100644 index 000000000000..112477a93e7e --- /dev/null +++ b/src/infra/gateway-supervision.ts @@ -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)); + } +} diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index ec0ee7ca42a6..8d7a7706772b 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -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"); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 203f826d789c..588019699fbb 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -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 { + 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;