From 7e59cdc50d53f788ac16ca3f7e1811aabdc08628 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 17 May 2026 16:50:28 +0800 Subject: [PATCH] fix(update): preserve package service state during cutover --- src/cli/update-cli/update-command.test.ts | 35 +++++++ src/cli/update-cli/update-command.ts | 17 ++- src/daemon/launchd-plist.ts | 121 ++++++++++++++++++++-- src/daemon/launchd.test.ts | 57 ++++++++++ src/daemon/launchd.ts | 19 +++- 5 files changed, 238 insertions(+), 11 deletions(-) diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index f575347277b..d0abd56a237 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -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({ diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 6f3f7847fe2..59952888a59 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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 { if (shouldRestart) { try { const serviceState = await readGatewayServiceState(resolveGatewayService(), { - env: process.env, + env: resolvePostUpdateServiceStateReadEnv({ + updateMode: resultWithPostUpdate.mode, + processEnv: process.env, + prePackageServiceEnv: prePackageServiceStop?.serviceEnv, + }), }); if ( shouldPrepareUpdatedInstallRestart({ diff --git a/src/daemon/launchd-plist.ts b/src/daemon/launchd-plist.ts index 74263266f70..dd41553ae21 100644 --- a/src/daemon/launchd-plist.ts +++ b/src/daemon/launchd-plist.ts @@ -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> { 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 = {}; @@ -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; environmentValueSources?: Record; sourcePath?: string; +} | null>; +export async function readLaunchAgentProgramArgumentsFromFile( + plistPath: string, + options: ReadLaunchAgentProgramArgumentsOptions, +): Promise<{ + programArguments: string[]; + workingDirectory?: string; + environment?: Record; + environmentValueSources?: Record; + sourcePath?: string; +} | null>; +export async function readLaunchAgentProgramArgumentsFromFile( + plistPath: string, + options?: ReadLaunchAgentProgramArgumentsOptions, +): Promise<{ + programArguments: string[]; + workingDirectory?: string; + environment?: Record; + environmentValueSources?: Record; + 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 = {}; for (const key of Object.keys(inlineEnvironment)) { diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 0d2f5949bc2..0cf6bc5512b 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -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(`${callerWrapperPath}`); + expect(rewritten).toContain(`${callerEnvFilePath}`); + 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"; diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 84ec01fc871..ef3aa681d4f 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -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 { + 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 { - const existing = await readLaunchAgentProgramArgumentsFromFile(plistPath); + const existing = await readLaunchAgentProgramArgumentsFromFile( + plistPath, + resolveLaunchAgentEnvironmentReadOptions(env, label), + ); if (!existing?.programArguments.length) { return false; }