fix #98107: Gateway regenerates service-env file on every restart, wiping Telegram bot tokens (#99124)

* Preserve LaunchAgent env-file SecretRefs

* fix gateway start repair env source preservation

* test(daemon): cover SecretRef ownership cleanup

Co-authored-by: mushuiyu886 <mushuiyu886@users.noreply.github.com>

* fix(daemon): preserve only active SecretRefs

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* chore: defer service SecretRefs changelog

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: mushuiyu886 <mushuiyu886@users.noreply.github.com>
This commit is contained in:
mushuiyu886
2026-07-06 09:24:39 +08:00
committed by GitHub
parent f12312d69b
commit 08852d2bf7
5 changed files with 315 additions and 17 deletions

View File

@@ -0,0 +1,150 @@
// Start repair tests cover stale service repair install-plan wiring.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayService, GatewayServiceState } from "../../daemon/service.js";
const buildGatewayInstallPlanMock = vi.hoisted(() =>
vi.fn(
async (params: {
existingEnvironment?: Record<string, string | undefined>;
existingEnvironmentValueSources?: Record<
string,
"inline" | "file" | "inline-and-file" | undefined
>;
}) => {
const preservedFileValue =
params.existingEnvironmentValueSources?.TELEGRAM_DEFAULT_BOTTOKEN === "file";
return {
programArguments: ["/usr/bin/openclaw", "gateway", "run"],
workingDirectory: "/tmp/openclaw",
environment: {
TELEGRAM_DEFAULT_BOTTOKEN: preservedFileValue
? params.existingEnvironment?.TELEGRAM_DEFAULT_BOTTOKEN
: "placeholder-overwritten-token",
},
environmentValueSources: {
TELEGRAM_DEFAULT_BOTTOKEN: preservedFileValue ? "file" : "inline",
},
};
},
),
);
const resolveGatewayInstallTokenMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotForWriteMock = vi.hoisted(() => vi.fn());
const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789));
const resolveOpenClawWrapperPathMock = vi.hoisted(() => vi.fn());
const formatGatewayServiceStartRepairIssuesMock = vi.hoisted(() => vi.fn());
const defaultRuntimeLogMock = vi.hoisted(() => vi.fn());
vi.mock("../../commands/daemon-install-helpers.js", () => ({
buildGatewayInstallPlan: buildGatewayInstallPlanMock,
}));
vi.mock("../../commands/daemon-runtime.js", () => ({
DEFAULT_GATEWAY_DAEMON_RUNTIME: "node",
}));
vi.mock("../../commands/gateway-install-token.js", () => ({
resolveGatewayInstallToken: resolveGatewayInstallTokenMock,
}));
vi.mock("../../config/io.js", () => ({
readConfigFileSnapshotForWrite: readConfigFileSnapshotForWriteMock,
}));
vi.mock("../../config/paths.js", () => ({
resolveGatewayPort: resolveGatewayPortMock,
}));
vi.mock("../../daemon/program-args.js", () => ({
OPENCLAW_WRAPPER_ENV_KEY: "OPENCLAW_WRAPPER",
resolveOpenClawWrapperPath: resolveOpenClawWrapperPathMock,
}));
vi.mock("../../daemon/service.js", () => ({
formatGatewayServiceStartRepairIssues: formatGatewayServiceStartRepairIssuesMock,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: { log: defaultRuntimeLogMock },
}));
const { repairLoadedGatewayServiceForStart } = await import("./start-repair.js");
function readFirstInstallPlanArg(): Record<string, unknown> {
const [firstArg] = buildGatewayInstallPlanMock.mock.calls[0] ?? [];
if (!firstArg) {
throw new Error("expected first install plan call");
}
return firstArg as Record<string, unknown>;
}
describe("repairLoadedGatewayServiceForStart", () => {
beforeEach(() => {
buildGatewayInstallPlanMock.mockClear();
resolveGatewayInstallTokenMock.mockReset();
readConfigFileSnapshotForWriteMock.mockReset();
resolveGatewayPortMock.mockClear();
resolveOpenClawWrapperPathMock.mockReset();
formatGatewayServiceStartRepairIssuesMock.mockReset();
defaultRuntimeLogMock.mockClear();
resolveGatewayInstallTokenMock.mockResolvedValue({
tokenRefConfigured: false,
warnings: [],
});
readConfigFileSnapshotForWriteMock.mockResolvedValue({
snapshot: { exists: true, valid: true, sourceConfig: {}, config: {} },
writeOptions: { expectedConfigPath: "/tmp/openclaw.json" },
});
resolveOpenClawWrapperPathMock.mockResolvedValue("/usr/bin/openclaw");
formatGatewayServiceStartRepairIssuesMock.mockReturnValue(
"service was installed by an older version",
);
});
it("forwards existing env value-source metadata when repairing stale service definitions", async () => {
const installMock = vi.fn(async () => {});
const isLoadedMock = vi.fn(async () => true);
const service = {
install: installMock,
isLoaded: isLoadedMock,
} as unknown as GatewayService;
const existingEnvironment = {
OPENCLAW_SERVICE_VERSION: "2026.4.24",
TELEGRAM_DEFAULT_BOTTOKEN: "existing-env-file-token",
};
const existingEnvironmentValueSources = {
OPENCLAW_SERVICE_VERSION: "inline" as const,
TELEGRAM_DEFAULT_BOTTOKEN: "file" as const,
};
const state: GatewayServiceState = {
installed: true,
loaded: true,
running: false,
env: {},
command: {
programArguments: ["/usr/bin/openclaw", "gateway", "run"],
environment: existingEnvironment,
environmentValueSources: existingEnvironmentValueSources,
},
};
await repairLoadedGatewayServiceForStart({
service,
state,
issues: [{ code: "version-mismatch", message: "old service" }],
json: true,
stdout: process.stdout,
});
const planArg = readFirstInstallPlanArg();
expect(planArg.existingEnvironment).toBe(existingEnvironment);
expect(planArg.existingEnvironmentValueSources).toBe(existingEnvironmentValueSources);
expect(installMock).toHaveBeenCalledWith(
expect.objectContaining({
environment: { TELEGRAM_DEFAULT_BOTTOKEN: "existing-env-file-token" },
environmentValueSources: { TELEGRAM_DEFAULT_BOTTOKEN: "file" },
}),
);
});
});

View File

@@ -28,6 +28,7 @@ export async function repairLoadedGatewayServiceForStart(params: {
await readConfigFileSnapshotForWrite();
const cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config;
const existingEnvironment = params.state.command?.environment;
const existingEnvironmentValueSources = params.state.command?.environmentValueSources;
const installEnv = mergeInstallInvocationEnv({
env: process.env,
existingServiceEnv: existingEnvironment,
@@ -65,6 +66,7 @@ export async function repairLoadedGatewayServiceForStart(params: {
runtime: DEFAULT_GATEWAY_DAEMON_RUNTIME,
wrapperPath,
existingEnvironment,
existingEnvironmentValueSources,
config: cfg,
warn: (message) => {
warnings.push(message);