From 0b3f13b3375f2a4a05aefaa7517217a4b36d9982 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 03:07:34 +0100 Subject: [PATCH] fix: preserve wrapper env during gateway reinstall --- src/cli/daemon-cli/install.test.ts | 79 +++++++++++++++++++++++++----- src/cli/daemon-cli/install.ts | 15 ++++-- 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 65a2aeccfa1..1099d4c3e66 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -30,13 +30,22 @@ const resolveGatewayAuthMock = vi.hoisted(() => ); const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn()); const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token")); -const buildGatewayInstallPlanMock = vi.hoisted(() => - vi.fn(async () => ({ - programArguments: ["openclaw", "gateway", "run"], - workingDirectory: "/tmp", - environment: {}, - })), -); +const createInstallPlanFixture = vi.hoisted(() => { + return async (params?: { wrapperPath?: string; env?: Record }) => { + const environment: Record = {}; + if (params?.wrapperPath || params?.env?.OPENCLAW_WRAPPER) { + environment.OPENCLAW_WRAPPER = params.wrapperPath ?? params.env?.OPENCLAW_WRAPPER; + } + return { + programArguments: params?.wrapperPath + ? [params.wrapperPath, "gateway", "run"] + : ["openclaw", "gateway", "run"], + workingDirectory: "/tmp", + environment, + }; + }; +}); +const buildGatewayInstallPlanMock = vi.hoisted(() => vi.fn(createInstallPlanFixture)); const parsePortMock = vi.hoisted(() => vi.fn(() => null)); const isGatewayDaemonRuntimeMock = vi.hoisted(() => vi.fn(() => true)); const installDaemonServiceAndEmitMock = vi.hoisted(() => vi.fn(async () => {})); @@ -108,6 +117,11 @@ vi.mock("../../commands/daemon-install-helpers.js", () => ({ buildGatewayInstallPlan: buildGatewayInstallPlanMock, })); +vi.mock("../../daemon/program-args.js", () => ({ + OPENCLAW_WRAPPER_ENV_KEY: "OPENCLAW_WRAPPER", + resolveOpenClawWrapperPath: async (value: string | undefined) => value?.trim() || undefined, +})); + vi.mock("./shared.js", () => ({ parsePort: parsePortMock, createDaemonInstallActionContext: (jsonFlag: unknown) => { @@ -188,6 +202,7 @@ describe("runDaemonInstall", () => { installDaemonServiceAndEmitMock.mockReset(); service.isLoaded.mockReset(); service.stage.mockReset(); + service.readCommand.mockReset(); resetRuntimeCapture(); actionState.warnings.length = 0; actionState.emitted.length = 0; @@ -211,11 +226,7 @@ describe("runDaemonInstall", () => { }); resolveSecretRefValuesMock.mockResolvedValue(new Map()); randomTokenMock.mockReturnValue("generated-token"); - buildGatewayInstallPlanMock.mockResolvedValue({ - programArguments: ["openclaw", "gateway", "run"], - workingDirectory: "/tmp", - environment: {}, - }); + buildGatewayInstallPlanMock.mockImplementation(createInstallPlanFixture); parsePortMock.mockReturnValue(null); isGatewayDaemonRuntimeMock.mockReturnValue(true); installDaemonServiceAndEmitMock.mockResolvedValue(undefined); @@ -402,6 +413,50 @@ describe("runDaemonInstall", () => { expect(actionState.emitted.at(-1)).toMatchObject({ result: "already-installed" }); }); + it("preserves wrapper env from an installed but unloaded service during forced reinstall", async () => { + service.isLoaded.mockResolvedValue(false); + service.readCommand.mockResolvedValue({ + programArguments: ["/usr/local/bin/openclaw-doppler", "gateway", "run"], + environment: { + OPENCLAW_WRAPPER: "/usr/local/bin/openclaw-doppler", + }, + } as never); + + await runDaemonInstall({ json: true, force: true }); + + expect(service.readCommand).toHaveBeenCalledTimes(1); + expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + wrapperPath: "/usr/local/bin/openclaw-doppler", + existingEnvironment: expect.objectContaining({ + OPENCLAW_WRAPPER: "/usr/local/bin/openclaw-doppler", + }), + env: expect.objectContaining({ + OPENCLAW_WRAPPER: "/usr/local/bin/openclaw-doppler", + }), + }), + ); + expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); + }); + + it("reinstalls when wrapper command matches but wrapper env is missing", async () => { + service.isLoaded.mockResolvedValue(true); + service.readCommand.mockResolvedValue({ + programArguments: ["/usr/local/bin/openclaw-doppler", "gateway", "run"], + environment: {}, + } as never); + + await runDaemonInstall({ + json: true, + wrapper: "/usr/local/bin/openclaw-doppler", + }); + + expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); + expect(actionState.warnings).toContain( + "Gateway service OPENCLAW_WRAPPER differs from the current wrapper install plan; refreshing the install.", + ); + }); + it("reinstalls when the embedded gateway token differs from the install plan", async () => { service.isLoaded.mockResolvedValue(true); service.readCommand.mockResolvedValue({ diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index c0b65ee9382..62fbabae874 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -135,10 +135,8 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { return; } } - if (loaded) { - existingServiceCommand = await service.readCommand(process.env).catch(() => null); - existingServiceEnv = existingServiceCommand?.environment; - } + existingServiceCommand = await service.readCommand(process.env).catch(() => null); + existingServiceEnv = existingServiceCommand?.environment; const installEnv = mergeInstallInvocationEnv({ env: process.env, existingServiceEnv, @@ -294,6 +292,15 @@ async function getGatewayServiceAutoRefreshMessage(params: { ) { return "Gateway service command differs from the current wrapper install plan; refreshing the install."; } + const plannedWrapperPath = normalizeOptionalString( + plannedInstall.environment[OPENCLAW_WRAPPER_ENV_KEY], + ); + const currentWrapperPath = normalizeOptionalString( + currentCommand.environment?.[OPENCLAW_WRAPPER_ENV_KEY], + ); + if (plannedWrapperPath !== currentWrapperPath) { + return `Gateway service ${OPENCLAW_WRAPPER_ENV_KEY} differs from the current wrapper install plan; refreshing the install.`; + } } const currentExecPath = currentCommand.programArguments[0]?.trim(); if (!currentExecPath) {