fix(gateway): stop start-time repair from retargeting managed services (#115935)

This commit is contained in:
Peter Steinberger
2026-07-29 13:41:19 -04:00
committed by GitHub
parent 68dbf92281
commit e80fe942c8
6 changed files with 403 additions and 12 deletions
-2
View File
@@ -500,7 +500,6 @@ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) {
repairLoadedService: async ({ json, stdout, warn, state, issues }) =>
await repairLoadedGatewayServiceForStart({
service,
port: expectedPort,
json,
stdout,
warn,
@@ -600,7 +599,6 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
const result = await repairLoadedGatewayServiceForStart({
action: "restart",
service,
port: configuredPort,
json,
stdout,
warn,
+219 -2
View File
@@ -1,5 +1,5 @@
// Start repair tests cover stale service repair install-plan wiring.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayService, GatewayServiceState } from "../../daemon/service.js";
const buildGatewayInstallPlanMock = vi.hoisted(() =>
@@ -30,7 +30,24 @@ const buildGatewayInstallPlanMock = vi.hoisted(() =>
);
const resolveGatewayInstallTokenMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotForWriteMock = vi.hoisted(() => vi.fn());
const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789));
const resolveGatewayPortMock = vi.hoisted(() =>
vi.fn(
(config: { gateway?: { port?: number } } | undefined, env: NodeJS.ProcessEnv = process.env) => {
const portMatch = env.OPENCLAW_GATEWAY_PORT?.trim().match(/(?:^|:)(\d+)$/);
return Number(portMatch?.[1]) || config?.gateway?.port || 18_789;
},
),
);
const resolveStateDirMock = vi.hoisted(() =>
vi.fn((env: NodeJS.ProcessEnv) => env.OPENCLAW_STATE_DIR?.trim() || `${env.HOME}/.openclaw`),
);
const resolveConfigPathCandidateMock = vi.hoisted(() =>
vi.fn(
(env: NodeJS.ProcessEnv) =>
env.OPENCLAW_CONFIG_PATH?.trim() ||
`${env.OPENCLAW_STATE_DIR?.trim() || `${env.HOME}/.openclaw`}/openclaw.json`,
),
);
const resolveOpenClawWrapperPathMock = vi.hoisted(() => vi.fn());
const formatGatewayServiceStartRepairIssuesMock = vi.hoisted(() => vi.fn());
const defaultRuntimeLogMock = vi.hoisted(() => vi.fn());
@@ -53,7 +70,9 @@ vi.mock("../../config/io.js", () => ({
}));
vi.mock("../../config/paths.js", () => ({
resolveConfigPathCandidate: resolveConfigPathCandidateMock,
resolveGatewayPort: resolveGatewayPortMock,
resolveStateDir: resolveStateDirMock,
}));
vi.mock("../../daemon/program-args.js", () => ({
@@ -85,6 +104,12 @@ function readFirstInstallPlanArg(): Record<string, unknown> {
describe("repairLoadedGatewayServiceForStart", () => {
beforeEach(() => {
vi.stubEnv("HOME", "/home/openclaw");
vi.stubEnv("OPENCLAW_CONFIG_PATH", "");
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "");
vi.stubEnv("OPENCLAW_HOME", "");
vi.stubEnv("OPENCLAW_PROFILE", "");
vi.stubEnv("OPENCLAW_STATE_DIR", "");
buildGatewayInstallPlanMock.mockClear();
resolveGatewayInstallTokenMock.mockReset();
readConfigFileSnapshotForWriteMock.mockReset();
@@ -108,6 +133,10 @@ describe("repairLoadedGatewayServiceForStart", () => {
);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("forwards existing env value-source metadata when repairing stale service definitions", async () => {
const installMock = vi.fn(async () => {});
const isLoadedMock = vi.fn(async () => true);
@@ -116,6 +145,7 @@ describe("repairLoadedGatewayServiceForStart", () => {
isLoaded: isLoadedMock,
} as unknown as GatewayService;
const existingEnvironment = {
HOME: "/home/openclaw",
OPENCLAW_SERVICE_VERSION: "2026.4.24",
TELEGRAM_DEFAULT_BOTTOKEN: "existing-env-file-token",
};
@@ -153,4 +183,191 @@ describe("repairLoadedGatewayServiceForStart", () => {
}),
);
});
it.each(["start", "restart"] as const)(
"refuses %s repair when ambient state, config, and port target a different service",
async (action) => {
vi.stubEnv("OPENCLAW_STATE_DIR", "/home/openclaw/stress-state");
vi.stubEnv("OPENCLAW_CONFIG_PATH", "/home/openclaw/stress-state/openclaw.json");
readConfigFileSnapshotForWriteMock.mockResolvedValue({
snapshot: {
exists: true,
valid: true,
sourceConfig: { gateway: { port: 18_999 } },
config: { gateway: { port: 18_999 } },
},
writeOptions: { expectedConfigPath: "/home/openclaw/stress-state/openclaw.json" },
});
const originalUnit = [
"ExecStart=/usr/bin/openclaw gateway --port 18789",
"EnvironmentFile=-/home/openclaw/.openclaw/gateway.systemd.env",
"Environment=OPENCLAW_SERVICE_MANAGED_ENV_KEYS=OPENAI_API_KEY,OPENCLAW_GATEWAY_PASSWORD",
].join("\n");
let unit = originalUnit;
const installMock = vi.fn(async () => {
unit = "rewritten";
});
const service = {
install: installMock,
isLoaded: vi.fn(async () => true),
} as unknown as GatewayService;
const state: GatewayServiceState = {
installed: true,
loaded: true,
running: false,
env: {},
command: {
programArguments: ["/usr/bin/openclaw", "gateway", "--port", "18789"],
environment: {
HOME: "/home/openclaw",
OPENAI_API_KEY: "file-backed-openai-key",
OPENCLAW_GATEWAY_PASSWORD: "file-backed-password",
OPENCLAW_GATEWAY_PORT: "18789",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY,OPENCLAW_GATEWAY_PASSWORD",
},
environmentValueSources: {
HOME: "inline",
OPENAI_API_KEY: "file",
OPENCLAW_GATEWAY_PASSWORD: "file",
OPENCLAW_GATEWAY_PORT: "inline",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline",
},
},
};
const repairParams = {
service,
state,
issues: [{ code: "port-mismatch" as const, message: "old port" }],
json: true,
stdout: process.stdout,
};
const repair =
action === "restart"
? repairLoadedGatewayServiceForStart({ ...repairParams, action })
: repairLoadedGatewayServiceForStart(repairParams);
await expect(repair).rejects.toThrow(
[
"Refusing to repair the managed Gateway service because the current invocation targets a different Gateway:",
'- OPENCLAW_STATE_DIR: installed="/home/openclaw/.openclaw", ambient="/home/openclaw/stress-state"',
'- OPENCLAW_CONFIG_PATH: installed="/home/openclaw/.openclaw/openclaw.json", ambient="/home/openclaw/stress-state/openclaw.json"',
'- gateway.port: installed="18789", ambient="18999"',
`Run \`openclaw gateway ${action}\` with the installed state directory, config path, and port (or unset conflicting environment overrides). To retarget intentionally, run \`openclaw gateway install --force\`.`,
].join("\n"),
);
expect(unit).toBe(originalUnit);
expect(installMock).not.toHaveBeenCalled();
expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled();
expect(resolveGatewayInstallTokenMock).not.toHaveBeenCalled();
},
);
it("refuses a port-less stale service repair when ambient port overrides its config port", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18999");
readConfigFileSnapshotForWriteMock.mockResolvedValue({
snapshot: {
exists: true,
valid: true,
sourceConfig: { gateway: { port: 18_789 } },
config: { gateway: { port: 18_789 } },
},
writeOptions: { expectedConfigPath: "/home/openclaw/.openclaw/openclaw.json" },
});
const installMock = vi.fn(async () => {});
const service = {
install: installMock,
isLoaded: vi.fn(async () => true),
} as unknown as GatewayService;
const state: GatewayServiceState = {
installed: true,
loaded: true,
running: false,
env: {},
command: {
programArguments: ["/usr/bin/openclaw", "gateway"],
environment: { HOME: "/home/openclaw" },
},
};
await expect(
repairLoadedGatewayServiceForStart({
service,
state,
issues: [{ code: "version-mismatch", message: "old service" }],
json: true,
stdout: process.stdout,
}),
).rejects.toThrow('- gateway.port: installed="18789", ambient="18999"');
expect(installMock).not.toHaveBeenCalled();
expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled();
});
it("resolves installed host-and-port environment syntax before comparing repair targets", async () => {
const installMock = vi.fn(async () => {});
const service = {
install: installMock,
isLoaded: vi.fn(async () => true),
} as unknown as GatewayService;
const state: GatewayServiceState = {
installed: true,
loaded: true,
running: false,
env: {},
command: {
programArguments: ["/usr/bin/openclaw", "gateway"],
environment: {
HOME: "/home/openclaw",
OPENCLAW_GATEWAY_PORT: "127.0.0.1:19000",
},
},
};
await expect(
repairLoadedGatewayServiceForStart({
service,
state,
issues: [{ code: "version-mismatch", message: "old service" }],
json: true,
stdout: process.stdout,
}),
).rejects.toThrow('- gateway.port: installed="19000", ambient="18789"');
expect(installMock).not.toHaveBeenCalled();
expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled();
});
it("refuses repair when a legacy service does not identify its installed state directory", async () => {
vi.stubEnv("HOME", "/home/ambient-user");
const installMock = vi.fn(async () => {});
const service = {
install: installMock,
isLoaded: vi.fn(async () => true),
} as unknown as GatewayService;
const state: GatewayServiceState = {
installed: true,
loaded: true,
running: false,
env: {},
command: {
programArguments: ["/usr/bin/openclaw", "gateway", "--port", "18789"],
environment: { OPENCLAW_GATEWAY_PORT: "18789" },
},
};
await expect(
repairLoadedGatewayServiceForStart({
service,
state,
issues: [{ code: "version-mismatch", message: "old service" }],
json: true,
stdout: process.stdout,
}),
).rejects.toThrow("installed state directory cannot be determined");
expect(installMock).not.toHaveBeenCalled();
expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled();
});
});
+103 -7
View File
@@ -1,9 +1,14 @@
// Start-time service repair: rebuilds stale service definitions before starting Gateway.
import path from "node:path";
import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js";
import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../../commands/daemon-runtime.js";
import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js";
import { readConfigFileSnapshotForWrite } from "../../config/io.js";
import { resolveGatewayPort } from "../../config/paths.js";
import {
resolveConfigPathCandidate,
resolveGatewayPort,
resolveStateDir,
} from "../../config/paths.js";
import { OPENCLAW_WRAPPER_ENV_KEY, resolveOpenClawWrapperPath } from "../../daemon/program-args.js";
import type { GatewayServiceEnv } from "../../daemon/service-types.js";
import type {
@@ -13,13 +18,12 @@ import type {
} from "../../daemon/service.js";
import { formatGatewayServiceStartRepairIssues } from "../../daemon/service.js";
import { assertGatewayServiceMutationAllowed } from "../../infra/gateway-supervision.js";
import { parseTcpPort, parseTcpPortFromArgs } from "../../infra/tcp-port.js";
import { parseTcpPortFromArgs } from "../../infra/tcp-port.js";
import { defaultRuntime } from "../../runtime.js";
import { mergeInstallInvocationEnv } from "./install.js";
type GatewayServiceRepairParams = {
service: GatewayService;
port?: number;
state: GatewayServiceState;
issues: GatewayServiceStartRepairIssue[];
json: boolean;
@@ -34,6 +38,95 @@ type GatewayServiceRepairResult<TResult extends "restarted" | "started"> = {
loaded: boolean;
};
const GATEWAY_TARGET_ENV_KEYS = [
"HOME",
"USERPROFILE",
"OPENCLAW_HOME",
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_GATEWAY_PORT",
] as const;
function resolveInstalledGatewayTargetEnvironment(
existingEnvironment: Record<string, string> | undefined,
): NodeJS.ProcessEnv {
const installedEnv: NodeJS.ProcessEnv = {};
for (const key of GATEWAY_TARGET_ENV_KEYS) {
const value = existingEnvironment?.[key]?.trim();
if (value) {
installedEnv[key] = value;
}
}
return installedEnv;
}
function normalizeTargetPath(value: string): string {
const resolved = path.resolve(value);
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
// Start/restart may rewrite a stale service. Refuse before planning when that
// rewrite would adopt a different state, config, or port from the invoking shell.
function assertGatewayRepairTargetMatches(params: {
action: "restart" | "start";
config: Parameters<typeof resolveGatewayPort>[0];
existingEnvironment: Record<string, string> | undefined;
installedPort: number | null;
}): number {
const installedEnv = resolveInstalledGatewayTargetEnvironment(params.existingEnvironment);
const installedStateOverride = installedEnv.OPENCLAW_STATE_DIR?.trim();
const installedHome =
installedEnv.OPENCLAW_HOME?.trim() ||
installedEnv.HOME?.trim() ||
installedEnv.USERPROFILE?.trim();
if (!installedStateOverride && !installedHome) {
throw new Error(
`Refusing to repair the managed Gateway service because its installed state directory cannot be determined from the service definition. Run \`openclaw gateway install --force\` to replace it intentionally.`,
);
}
const installedStateDir = resolveStateDir(installedEnv);
const installedConfigPath = resolveConfigPathCandidate(installedEnv);
const ambientStateDir = resolveStateDir(process.env);
const ambientConfigPath = resolveConfigPathCandidate(process.env);
const ambientPort = resolveGatewayPort(params.config, process.env);
const sameConfigPath =
normalizeTargetPath(installedConfigPath) === normalizeTargetPath(ambientConfigPath);
const installedPort =
params.installedPort ??
(sameConfigPath ? resolveGatewayPort(params.config, installedEnv) : null);
const differences: Array<{ name: string; installed: string; ambient: string }> = [];
for (const [name, installed, ambient] of [
["OPENCLAW_STATE_DIR", installedStateDir, ambientStateDir],
["OPENCLAW_CONFIG_PATH", installedConfigPath, ambientConfigPath],
] as const) {
if (normalizeTargetPath(installed) !== normalizeTargetPath(ambient)) {
differences.push({ name, installed, ambient });
}
}
if (installedPort !== null && installedPort !== ambientPort) {
differences.push({
name: "gateway.port",
installed: String(installedPort),
ambient: String(ambientPort),
});
}
if (differences.length === 0) {
return installedPort ?? ambientPort;
}
const details = differences
.map(
({ name, installed, ambient }) =>
`- ${name}: installed=${JSON.stringify(installed)}, ambient=${JSON.stringify(ambient)}`,
)
.join("\n");
throw new Error(
`Refusing to repair the managed Gateway service because the current invocation targets a different Gateway:\n${details}\nRun \`openclaw gateway ${params.action}\` with the installed state directory, config path, and port (or unset conflicting environment overrides). To retarget intentionally, run \`openclaw gateway install --force\`.`,
);
}
/** Repair a loaded but stale Gateway service definition and report the start result. */
export function repairLoadedGatewayServiceForStart(
params: GatewayServiceRepairParams & { action: "restart" },
@@ -55,15 +148,18 @@ export async function repairLoadedGatewayServiceForStart(
const cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config;
const existingEnvironment = params.state.command?.environment;
const existingEnvironmentValueSources = params.state.command?.environmentValueSources;
const installedPort = parseTcpPortFromArgs(params.state.command?.programArguments);
const port = assertGatewayRepairTargetMatches({
action: params.action ?? "start",
config: cfg,
existingEnvironment,
installedPort,
});
const installEnv = mergeInstallInvocationEnv({
env: process.env,
existingServiceEnv: existingEnvironment,
});
const wrapperPath = await resolveOpenClawWrapperPath(installEnv[OPENCLAW_WRAPPER_ENV_KEY]);
const installedPort =
parseTcpPortFromArgs(params.state.command?.programArguments) ??
parseTcpPort(params.state.command?.environment?.OPENCLAW_GATEWAY_PORT);
const port = params.port ?? installedPort ?? resolveGatewayPort(cfg);
const tokenResolution = await resolveGatewayInstallToken({
config: cfg,