mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(gateway): stop start-time repair from retargeting managed services (#115935)
This commit is contained in:
committed by
GitHub
parent
68dbf92281
commit
e80fe942c8
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user