mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 03:01:34 +00:00
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:
150
src/cli/daemon-cli/start-repair.test.ts
Normal file
150
src/cli/daemon-cli/start-repair.test.ts
Normal 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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user