fix(update): preserve package service state during cutover

This commit is contained in:
Vincent Koc
2026-05-17 16:50:28 +08:00
parent 8dd91b14d3
commit 7e59cdc50d
5 changed files with 238 additions and 11 deletions

View File

@@ -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({

View File

@@ -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({

View File

@@ -27,6 +27,12 @@ const plistUnescape = (value: string): string =>
.replaceAll("&lt;", "<")
.replaceAll("&amp;", "&");
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)) {

View File

@@ -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";

View File

@@ -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;
}