mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 06:39:23 +02:00
fix(update): preserve package service state during cutover
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
recoverInstalledLaunchAgentAfterUpdate,
|
||||
recoverLaunchAgentAndRecheckGatewayHealth,
|
||||
resolvePostCoreUpdateChildStdio,
|
||||
resolvePostUpdateServiceStateReadEnv,
|
||||
resolvePostInstallDoctorEnv,
|
||||
shouldPrepareUpdatedInstallRestart,
|
||||
resolveUpdatedGatewayRestartPort,
|
||||
@@ -115,6 +116,40 @@ describe("resolveUpdatedGatewayRestartPort", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePostUpdateServiceStateReadEnv", () => {
|
||||
it("keeps package restart preparation anchored to the pre-update service env", () => {
|
||||
const processEnv = {
|
||||
OPENCLAW_STATE_DIR: "/source/state",
|
||||
OPENCLAW_CONFIG_PATH: "/source/openclaw.json",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const prePackageServiceEnv = {
|
||||
OPENCLAW_STATE_DIR: "/managed/state",
|
||||
OPENCLAW_CONFIG_PATH: "/managed/openclaw.json",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(
|
||||
resolvePostUpdateServiceStateReadEnv({
|
||||
updateMode: "npm",
|
||||
processEnv,
|
||||
prePackageServiceEnv,
|
||||
}),
|
||||
).toBe(prePackageServiceEnv);
|
||||
});
|
||||
|
||||
it("keeps git updates tied to the caller environment", () => {
|
||||
const processEnv = { OPENCLAW_STATE_DIR: "/source/state" } as NodeJS.ProcessEnv;
|
||||
const prePackageServiceEnv = { OPENCLAW_STATE_DIR: "/managed/state" } as NodeJS.ProcessEnv;
|
||||
|
||||
expect(
|
||||
resolvePostUpdateServiceStateReadEnv({
|
||||
updateMode: "git",
|
||||
processEnv,
|
||||
prePackageServiceEnv,
|
||||
}),
|
||||
).toBe(processEnv);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePostInstallDoctorEnv", () => {
|
||||
it("uses the managed service profile paths for post-install doctor", () => {
|
||||
const env = resolvePostInstallDoctorEnv({
|
||||
|
||||
@@ -1005,6 +1005,17 @@ export function resolveUpdatedGatewayRestartPort(params: {
|
||||
return resolveGatewayPort(params.config, params.serviceEnv ?? params.processEnv ?? process.env);
|
||||
}
|
||||
|
||||
export function resolvePostUpdateServiceStateReadEnv(params: {
|
||||
updateMode: UpdateRunResult["mode"];
|
||||
processEnv?: NodeJS.ProcessEnv;
|
||||
prePackageServiceEnv?: NodeJS.ProcessEnv;
|
||||
}): NodeJS.ProcessEnv {
|
||||
if (isPackageManagerUpdateMode(params.updateMode) && params.prePackageServiceEnv) {
|
||||
return params.prePackageServiceEnv;
|
||||
}
|
||||
return params.processEnv ?? process.env;
|
||||
}
|
||||
|
||||
type UpdateDryRunPreview = {
|
||||
dryRun: true;
|
||||
root: string;
|
||||
@@ -3235,7 +3246,11 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
if (shouldRestart) {
|
||||
try {
|
||||
const serviceState = await readGatewayServiceState(resolveGatewayService(), {
|
||||
env: process.env,
|
||||
env: resolvePostUpdateServiceStateReadEnv({
|
||||
updateMode: resultWithPostUpdate.mode,
|
||||
processEnv: process.env,
|
||||
prePackageServiceEnv: prePackageServiceStop?.serviceEnv,
|
||||
}),
|
||||
});
|
||||
if (
|
||||
shouldPrepareUpdatedInstallRestart({
|
||||
|
||||
@@ -27,6 +27,12 @@ const plistUnescape = (value: string): string =>
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll("&", "&");
|
||||
|
||||
type ReadLaunchAgentProgramArgumentsOptions = {
|
||||
expectedEnvironmentWrapperPath?: string;
|
||||
expectedEnvironmentFilePath?: string;
|
||||
generatedEnvironmentLabel?: string;
|
||||
};
|
||||
|
||||
function parseGeneratedEnvValue(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("'") || !trimmed.endsWith("'")) {
|
||||
@@ -35,17 +41,93 @@ function parseGeneratedEnvValue(value: string): string {
|
||||
return trimmed.slice(1, -1).replaceAll("'\\''", "'");
|
||||
}
|
||||
|
||||
function includesGeneratedEnvironmentPathToken(value: string | undefined, token: string): boolean {
|
||||
return Boolean(value?.replaceAll("\\", "/").includes(token));
|
||||
}
|
||||
|
||||
function includesGeneratedEnvironmentDirToken(value: string | undefined): boolean {
|
||||
return Boolean(value?.replaceAll("\\", "/").includes("/service-env/"));
|
||||
}
|
||||
|
||||
function resolveSiblingGeneratedEnvFilePath(
|
||||
envFilePath: string,
|
||||
options?: ReadLaunchAgentProgramArgumentsOptions,
|
||||
): string | undefined {
|
||||
const label = options?.generatedEnvironmentLabel?.trim();
|
||||
if (!label) {
|
||||
return undefined;
|
||||
}
|
||||
const serviceEnvMarker = "/service-env/";
|
||||
const markerIndex = envFilePath.replaceAll("\\", "/").lastIndexOf(serviceEnvMarker);
|
||||
if (markerIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
// Custom state dirs can also contain service-env; use the generated env dir closest to the file.
|
||||
const serviceEnvDirEnd = markerIndex + serviceEnvMarker.length - 1;
|
||||
return `${envFilePath.slice(0, serviceEnvDirEnd)}/${label}.env`;
|
||||
}
|
||||
|
||||
function isGeneratedEnvWrapperArgs(
|
||||
programArguments: string[],
|
||||
options?: ReadLaunchAgentProgramArgumentsOptions,
|
||||
): boolean {
|
||||
const wrapperPath = programArguments[0];
|
||||
const envFilePath = programArguments[1];
|
||||
if (!wrapperPath || !envFilePath) {
|
||||
return false;
|
||||
}
|
||||
if (!options) {
|
||||
return wrapperPath.endsWith("-env-wrapper.sh");
|
||||
}
|
||||
if (
|
||||
options.expectedEnvironmentWrapperPath &&
|
||||
options.expectedEnvironmentFilePath &&
|
||||
wrapperPath === options.expectedEnvironmentWrapperPath &&
|
||||
envFilePath === options.expectedEnvironmentFilePath
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const label = options.generatedEnvironmentLabel?.trim();
|
||||
if (!label) {
|
||||
return false;
|
||||
}
|
||||
// Legacy/corrupted plists may preserve the label-derived wrapper name inside
|
||||
// a mangled service-env path. Still unwrap it so the next rewrite can repair.
|
||||
return (
|
||||
includesGeneratedEnvironmentDirToken(wrapperPath) &&
|
||||
includesGeneratedEnvironmentDirToken(envFilePath) &&
|
||||
includesGeneratedEnvironmentPathToken(wrapperPath, `${label}-env-wrapper.sh`) &&
|
||||
includesGeneratedEnvironmentPathToken(envFilePath, `${label}.env`)
|
||||
);
|
||||
}
|
||||
|
||||
async function readLaunchAgentEnvironmentFile(
|
||||
programArguments: string[],
|
||||
options?: ReadLaunchAgentProgramArgumentsOptions,
|
||||
): Promise<Record<string, string>> {
|
||||
const envFilePath = programArguments[1];
|
||||
if (!programArguments[0]?.endsWith("-env-wrapper.sh") || !envFilePath) {
|
||||
if (!isGeneratedEnvWrapperArgs(programArguments, options) || !envFilePath) {
|
||||
return {};
|
||||
}
|
||||
let content = "";
|
||||
try {
|
||||
content = await fs.readFile(envFilePath, "utf8");
|
||||
} catch {
|
||||
const candidateEnvFilePaths = Array.from(
|
||||
new Set(
|
||||
[
|
||||
envFilePath,
|
||||
resolveSiblingGeneratedEnvFilePath(envFilePath, options),
|
||||
options?.expectedEnvironmentFilePath,
|
||||
].filter((candidate): candidate is string => Boolean(candidate)),
|
||||
),
|
||||
);
|
||||
for (const candidate of candidateEnvFilePaths) {
|
||||
try {
|
||||
content = await fs.readFile(candidate, "utf8");
|
||||
break;
|
||||
} catch {
|
||||
// Keep trying; mangled wrapper args may still have the canonical env file.
|
||||
}
|
||||
}
|
||||
if (!content) {
|
||||
return {};
|
||||
}
|
||||
const environment: Record<string, string> = {};
|
||||
@@ -68,8 +150,11 @@ async function readLaunchAgentEnvironmentFile(
|
||||
return environment;
|
||||
}
|
||||
|
||||
function unwrapGeneratedEnvWrapperArgs(programArguments: string[]): string[] {
|
||||
if (!programArguments[0]?.endsWith("-env-wrapper.sh") || !programArguments[1]) {
|
||||
function unwrapGeneratedEnvWrapperArgs(
|
||||
programArguments: string[],
|
||||
options?: ReadLaunchAgentProgramArgumentsOptions,
|
||||
): string[] {
|
||||
if (!isGeneratedEnvWrapperArgs(programArguments, options)) {
|
||||
return programArguments;
|
||||
}
|
||||
return programArguments.slice(2);
|
||||
@@ -100,6 +185,26 @@ export async function readLaunchAgentProgramArgumentsFromFile(plistPath: string)
|
||||
environment?: Record<string, string>;
|
||||
environmentValueSources?: Record<string, GatewayServiceEnvironmentValueSource>;
|
||||
sourcePath?: string;
|
||||
} | null>;
|
||||
export async function readLaunchAgentProgramArgumentsFromFile(
|
||||
plistPath: string,
|
||||
options: ReadLaunchAgentProgramArgumentsOptions,
|
||||
): Promise<{
|
||||
programArguments: string[];
|
||||
workingDirectory?: string;
|
||||
environment?: Record<string, string>;
|
||||
environmentValueSources?: Record<string, GatewayServiceEnvironmentValueSource>;
|
||||
sourcePath?: string;
|
||||
} | null>;
|
||||
export async function readLaunchAgentProgramArgumentsFromFile(
|
||||
plistPath: string,
|
||||
options?: ReadLaunchAgentProgramArgumentsOptions,
|
||||
): Promise<{
|
||||
programArguments: string[];
|
||||
workingDirectory?: string;
|
||||
environment?: Record<string, string>;
|
||||
environmentValueSources?: Record<string, GatewayServiceEnvironmentValueSource>;
|
||||
sourcePath?: string;
|
||||
} | null> {
|
||||
try {
|
||||
const plist = await fs.readFile(plistPath, "utf8");
|
||||
@@ -128,8 +233,8 @@ export async function readLaunchAgentProgramArgumentsFromFile(plistPath: string)
|
||||
inlineEnvironment[key] = value;
|
||||
}
|
||||
}
|
||||
const fileEnvironment = await readLaunchAgentEnvironmentFile(args);
|
||||
const effectiveProgramArguments = unwrapGeneratedEnvWrapperArgs(args);
|
||||
const fileEnvironment = await readLaunchAgentEnvironmentFile(args, options);
|
||||
const effectiveProgramArguments = unwrapGeneratedEnvWrapperArgs(args, options);
|
||||
const environment = { ...inlineEnvironment, ...fileEnvironment };
|
||||
const environmentValueSources: Record<string, GatewayServiceEnvironmentValueSource> = {};
|
||||
for (const key of Object.keys(inlineEnvironment)) {
|
||||
|
||||
@@ -726,6 +726,63 @@ describe("launchd install", () => {
|
||||
expect(command?.environmentValueSources?.OPENAI_API_KEY).toBe("file");
|
||||
});
|
||||
|
||||
it("repairs a mangled label-derived service-env wrapper path on restart", async () => {
|
||||
const callerEnv = createDefaultLaunchdEnv();
|
||||
const serviceEnv = {
|
||||
...callerEnv,
|
||||
OPENCLAW_STATE_DIR: "/Users/test/service-env/custom-state",
|
||||
};
|
||||
await installLaunchAgent({
|
||||
env: serviceEnv,
|
||||
stdout: new PassThrough(),
|
||||
programArguments: defaultProgramArguments,
|
||||
environment: {
|
||||
OPENCLAW_GATEWAY_PORT: "18789",
|
||||
OPENCLAW_STATE_DIR: serviceEnv.OPENCLAW_STATE_DIR,
|
||||
},
|
||||
});
|
||||
|
||||
const plistPath = resolveLaunchAgentPlistPath(callerEnv);
|
||||
const envFilePath = "/Users/test/service-env/custom-state/service-env/ai.openclaw.gateway.env";
|
||||
const wrapperPath =
|
||||
"/Users/test/service-env/custom-state/service-env/ai.openclaw.gateway-env-wrapper.sh";
|
||||
const callerEnvFilePath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway.env";
|
||||
const callerWrapperPath =
|
||||
"/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh";
|
||||
const mangledEnvFilePath =
|
||||
"/Users/test/service-env/custom-state/service-env/[ai.openclaw.gateway.env](http:/ai.openclaw.gateway.env)";
|
||||
const mangledWrapperPath =
|
||||
"/Users/test/service-env/custom-state/service-env/[ai.openclaw.gateway-env-wrapper.sh](http:/ai.openclaw.gateway-env-wrapper.sh)";
|
||||
state.files.set(
|
||||
plistPath,
|
||||
(state.files.get(plistPath) ?? "")
|
||||
.replace(wrapperPath, mangledWrapperPath)
|
||||
.replace(envFilePath, mangledEnvFilePath),
|
||||
);
|
||||
|
||||
const command = await readLaunchAgentProgramArguments(callerEnv);
|
||||
expect(command?.programArguments).toEqual(defaultProgramArguments);
|
||||
expect(command?.environment?.OPENCLAW_GATEWAY_PORT).toBe("18789");
|
||||
expect(command?.environment?.OPENCLAW_STATE_DIR).toBe(serviceEnv.OPENCLAW_STATE_DIR);
|
||||
expect(command?.environmentValueSources?.OPENCLAW_GATEWAY_PORT).toBe("file");
|
||||
|
||||
await restartLaunchAgent({
|
||||
env: callerEnv,
|
||||
stdout: new PassThrough(),
|
||||
});
|
||||
|
||||
const rewritten = state.files.get(plistPath) ?? "";
|
||||
expect(rewritten).toContain(`<string>${callerWrapperPath}</string>`);
|
||||
expect(rewritten).toContain(`<string>${callerEnvFilePath}</string>`);
|
||||
expect(rewritten).not.toContain(mangledEnvFilePath);
|
||||
expect(rewritten).not.toContain(mangledWrapperPath);
|
||||
const rewrittenEnv = state.files.get(callerEnvFilePath) ?? "";
|
||||
expect(rewrittenEnv).toContain("export OPENCLAW_GATEWAY_PORT='18789'");
|
||||
expect(rewrittenEnv).toContain(
|
||||
"export OPENCLAW_STATE_DIR='/Users/test/service-env/custom-state'",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the LaunchAgent TMPDIR before bootstrap", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const tmpDir = "/Users/test/.openclaw/tmp";
|
||||
|
||||
@@ -232,11 +232,23 @@ export function resolveLaunchAgentPlistPath(env: GatewayServiceEnv): string {
|
||||
return resolveLaunchAgentPlistPathForLabel(env, label);
|
||||
}
|
||||
|
||||
function resolveLaunchAgentEnvironmentReadOptions(env: GatewayServiceEnv, label: string) {
|
||||
return {
|
||||
expectedEnvironmentWrapperPath: resolveLaunchAgentEnvWrapperPath(env, label),
|
||||
expectedEnvironmentFilePath: resolveLaunchAgentEnvFilePath(env, label),
|
||||
generatedEnvironmentLabel: label,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readLaunchAgentProgramArguments(
|
||||
env: GatewayServiceEnv,
|
||||
): Promise<GatewayServiceCommandConfig | null> {
|
||||
const label = resolveLaunchAgentLabel({ env });
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
return readLaunchAgentProgramArgumentsFromFile(plistPath);
|
||||
return readLaunchAgentProgramArgumentsFromFile(
|
||||
plistPath,
|
||||
resolveLaunchAgentEnvironmentReadOptions(env, label),
|
||||
);
|
||||
}
|
||||
|
||||
function buildLaunchAgentPlist({
|
||||
@@ -926,7 +938,10 @@ async function rewriteLaunchAgentPlistForRestart({
|
||||
label: string;
|
||||
plistPath: string;
|
||||
}): Promise<boolean> {
|
||||
const existing = await readLaunchAgentProgramArgumentsFromFile(plistPath);
|
||||
const existing = await readLaunchAgentProgramArgumentsFromFile(
|
||||
plistPath,
|
||||
resolveLaunchAgentEnvironmentReadOptions(env, label),
|
||||
);
|
||||
if (!existing?.programArguments.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user