diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index ec2822743fe..f72123acdf4 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -413,4 +413,38 @@ describe("runDaemonInstall", () => { } } }); + + it("does not reuse stale service control env during forced reinstall", async () => { + service.isLoaded.mockResolvedValue(true); + service.readCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: { + OPENCLAW_STATE_DIR: "/tmp/openclaw-doctor-manual", + OPENCLAW_CONFIG_PATH: "/tmp/openclaw-doctor-manual/openclaw.json", + OPENCLAW_GATEWAY_TOKEN: "stale-service-token", + PATH: "/tmp/doctor-bin:/usr/bin", + NODE_OPTIONS: "--require /tmp/evil.js", + OPENAI_API_KEY: "service-openai-key", + }, + } as never); + + await runDaemonInstall({ json: true, force: true }); + + expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + OPENAI_API_KEY: "service-openai-key", + }), + }), + ); + const [firstArg] = + (buildGatewayInstallPlanMock.mock.calls.at(0) as [Record] | undefined) ?? []; + const env = firstArg?.env as Record; + expect(env.OPENCLAW_STATE_DIR).toBeUndefined(); + expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined(); + expect(env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined(); + expect(env.NODE_OPTIONS).toBeUndefined(); + expect(env.PATH).not.toContain("/tmp/doctor-bin"); + expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index ffdea598647..c223e69629f 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -9,6 +9,11 @@ import { readConfigFileSnapshotForWrite } from "../../config/io.js"; import { resolveGatewayPort } from "../../config/paths.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js"; +import { + isDangerousHostEnvOverrideVarName, + isDangerousHostEnvVarName, + normalizeEnvVarKey, +} from "../../infra/host-env-security.js"; import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; import { buildDaemonServiceSnapshot, installDaemonServiceAndEmit } from "./response.js"; @@ -26,8 +31,32 @@ function mergeInstallInvocationEnv(params: { if (!params.existingServiceEnv || Object.keys(params.existingServiceEnv).length === 0) { return params.env; } + const preservedServiceEnv: NodeJS.ProcessEnv = {}; + for (const [rawKey, rawValue] of Object.entries(params.existingServiceEnv)) { + const key = normalizeEnvVarKey(rawKey, { portable: true }); + if (!key) { + continue; + } + const upper = key.toUpperCase(); + if ( + upper === "HOME" || + upper === "PATH" || + upper === "TMPDIR" || + upper.startsWith("OPENCLAW_") + ) { + continue; + } + if (isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)) { + continue; + } + const value = rawValue.trim(); + if (!value) { + continue; + } + preservedServiceEnv[key] = value; + } return { - ...params.existingServiceEnv, + ...preservedServiceEnv, ...params.env, }; }