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);

View File

@@ -1307,6 +1307,78 @@ describe("buildGatewayInstallPlan — dotenv merge", () => {
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("TELEGRAM_DEFAULT_BOTTOKEN");
});
it("retains existing generated env-file SecretRef values for macOS LaunchAgent regeneration", async () => {
mockNodeGatewayPlanFixture({
serviceEnvironment: {
HOME: "/from-service",
OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway",
OPENCLAW_PORT: "3000",
},
});
const plan = await buildGatewayInstallPlan({
env: { HOME: tmpDir },
port: 3000,
runtime: "node",
platform: "darwin",
existingEnvironment: {
TELEGRAM_DEFAULT_BOTTOKEN: "telegram-existing-env-file-token",
TELEGRAM_HERMES_BOTTOKEN: "telegram-existing-hermes-env-file-token",
RETIRED_BOTTOKEN: "retired-env-file-token",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS:
"RETIRED_BOTTOKEN,TELEGRAM_DEFAULT_BOTTOKEN,TELEGRAM_HERMES_BOTTOKEN",
},
existingEnvironmentValueSources: {
TELEGRAM_DEFAULT_BOTTOKEN: "file",
TELEGRAM_HERMES_BOTTOKEN: "file",
RETIRED_BOTTOKEN: "file",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline",
},
config: {
env: {
vars: {
OPENROUTER_API_KEY: "openrouter-config-key",
TELEGRAM_DEFAULT_BOTTOKEN: "your-real-telegram-default-token-here",
TELEGRAM_HERMES_BOTTOKEN: "your-real-telegram-hermes-token-here",
},
},
channels: {
telegram: {
accounts: {
default: {
botToken: {
source: "env",
provider: "default",
id: "TELEGRAM_DEFAULT_BOTTOKEN",
},
},
hermes: {
botToken: {
source: "env",
provider: "default",
id: "TELEGRAM_HERMES_BOTTOKEN",
},
},
},
},
},
} as unknown as OpenClawConfig,
});
expect(plan.environment.TELEGRAM_DEFAULT_BOTTOKEN).toBe("telegram-existing-env-file-token");
expect(plan.environment.TELEGRAM_HERMES_BOTTOKEN).toBe(
"telegram-existing-hermes-env-file-token",
);
expect(plan.environmentValueSources?.TELEGRAM_DEFAULT_BOTTOKEN).toBe("file");
expect(plan.environmentValueSources?.TELEGRAM_HERMES_BOTTOKEN).toBe("file");
expect(plan.environment.RETIRED_BOTTOKEN).toBeUndefined();
expect(plan.environmentValueSources?.RETIRED_BOTTOKEN).toBeUndefined();
expect(plan.environment.OPENROUTER_API_KEY).toBeUndefined();
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe(
"OPENROUTER_API_KEY,TELEGRAM_DEFAULT_BOTTOKEN,TELEGRAM_HERMES_BOTTOKEN",
);
});
it("retains .env values when config env has an unresolved self reference", async () => {
await writeStateDirDotEnv("MINIMAX_API_KEY=minimax-dotenv-key\n", {
stateDir: path.join(tmpDir, ".openclaw"),
@@ -1363,6 +1435,14 @@ describe("buildGatewayInstallPlan — dotenv merge", () => {
port: 3000,
runtime: "node",
platform: "darwin",
existingEnvironment: {
BRAVE_API_KEY: "stale-generated-value",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "BRAVE_API_KEY",
},
existingEnvironmentValueSources: {
BRAVE_API_KEY: "file",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline",
},
config: {
env: {
vars: {

View File

@@ -23,6 +23,7 @@ import { applyManagedServiceEnvRenderPolicy } from "../daemon/service-env-render
import { buildServiceEnvironment } from "../daemon/service-env.js";
import {
formatManagedServiceEnvKeys,
hasEnvironmentFileSource,
readManagedServiceEnvKeysFromEnvironment,
} from "../daemon/service-managed-env.js";
import { isNonMinimalServicePathEntry } from "../daemon/service-path-policy.js";
@@ -180,17 +181,18 @@ type ExecSecretRefPassEnvSource = {
warningTitle: "Config SecretRef" | "Auth profile" | "Plugin config SecretRef";
};
function collectConfigSecretRefServiceEnvVars(params: {
function collectConfigSecretRefServiceEnvSources(params: {
env: Record<string, string | undefined>;
config?: OpenClawConfig;
configContainsSecretRef: boolean;
stateDirDotEnvEnvironment: Record<string, string | undefined>;
warn?: DaemonInstallWarnFn;
}): Record<string, string> {
}): { keys: string[]; environment: Record<string, string> } {
const keys = new Set<string>();
const environment: Record<string, string> = {};
if (!params.config || !params.configContainsSecretRef) {
return {};
return { keys: [], environment };
}
const entries: Record<string, string> = {};
for (const target of discoverConfigSecretTargets(params.config)) {
if (!target.entry.includeInPlan) {
continue;
@@ -221,6 +223,7 @@ function collectConfigSecretRefServiceEnvVars(params: {
);
continue;
}
keys.add(key.toUpperCase());
if (Object.hasOwn(params.stateDirDotEnvEnvironment, key)) {
continue;
}
@@ -228,9 +231,9 @@ function collectConfigSecretRefServiceEnvVars(params: {
if (!value) {
continue;
}
entries[key] = value;
environment[key] = value;
}
return entries;
return { keys: [...keys], environment };
}
function collectExecSecretRefPassEnvServiceEnvVars(params: {
@@ -515,6 +518,46 @@ function readExistingEnvironmentValueSource(params: {
return undefined;
}
function collectExistingEnvironmentFileManagedServiceEnvVars(params: {
existingEnvironment: Record<string, string | undefined> | undefined;
existingEnvironmentValueSources?: Record<
string,
GatewayServiceEnvironmentValueSource | undefined
>;
configSecretRefKeys: ReadonlySet<string>;
}): Record<string, string | undefined> {
if (!params.existingEnvironment || params.configSecretRefKeys.size === 0) {
return {};
}
const preserved: Record<string, string | undefined> = {};
for (const [rawKey, rawValue] of Object.entries(params.existingEnvironment)) {
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (!key) {
continue;
}
const normalizedKey = key.toUpperCase();
if (!params.configSecretRefKeys.has(normalizedKey)) {
continue;
}
if (isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)) {
continue;
}
const source = readExistingEnvironmentValueSource({
existingEnvironmentValueSources: params.existingEnvironmentValueSources,
normalizedKey,
});
if (!hasEnvironmentFileSource(source)) {
continue;
}
const value = rawValue?.trim();
if (!value) {
continue;
}
preserved[key] = value;
}
return preserved;
}
function omitEnvironmentEntriesShadowedBy(
entries: Record<string, string | undefined>,
shadowEntries: Array<Record<string, string | undefined>>,
@@ -572,13 +615,14 @@ async function buildGatewayInstallEnvironment(params: {
});
// Full target discovery materializes plugin metadata; configs without refs do not need it.
const containsConfigSecretRef = configContainsSecretRef(params.config);
const configSecretRefEnvironment = collectConfigSecretRefServiceEnvVars({
env: params.env,
config: params.config,
configContainsSecretRef: containsConfigSecretRef,
stateDirDotEnvEnvironment,
warn: params.warn,
});
const { keys: configSecretRefKeys, environment: configSecretRefEnvironment } =
collectConfigSecretRefServiceEnvSources({
env: params.env,
config: params.config,
configContainsSecretRef: containsConfigSecretRef,
stateDirDotEnvEnvironment,
warn: params.warn,
});
const authStore = await resolveAuthProfileStoreForServiceEnv(params.authStore);
const execSecretRefPassEnvEnvironment = collectExecSecretRefPassEnvServiceEnvVars({
env: params.env,
@@ -619,18 +663,36 @@ async function buildGatewayInstallEnvironment(params: {
addServiceEnvPlanEntries(plan, configSecretRefEnvironment, {});
addServiceEnvPlanEntries(plan, execSecretRefPassEnvEnvironment, {});
addServiceEnvPlanEntries(plan, authProfileEnvironment, {});
const configSecretRefKeyEnvironment = Object.fromEntries(
configSecretRefKeys.map((key) => [key, "1"]),
);
const managedServiceEnvKeys = formatManagedServiceEnvKeys(
{
...durableEnvironment,
...configSecretRefKeyEnvironment,
...configSecretRefEnvironment,
},
{ omitKeys: Object.keys(params.serviceEnvironment) },
);
const existingEnvironmentFileRenderEnvironment = omitEnvironmentEntriesShadowedBy(
collectExistingEnvironmentFileManagedServiceEnvVars({
existingEnvironment: params.existingEnvironment,
existingEnvironmentValueSources: params.existingEnvironmentValueSources,
configSecretRefKeys: new Set(configSecretRefKeys),
}),
[
stateDirDotEnvRenderEnvironment,
configSecretRefEnvironment,
execSecretRefPassEnvEnvironment,
authProfileEnvironment,
],
);
applyManagedServiceEnvRenderPolicy({
plan,
managedServiceEnvKeys,
serviceEnvironment: params.serviceEnvironment,
platform: params.platform,
existingEnvironmentFileEnvironment: existingEnvironmentFileRenderEnvironment,
stateDirDotEnvEnvironment: stateDirDotEnvRenderEnvironment,
configSecretRefEnvironment,
});

View File

@@ -1,8 +1,5 @@
/** Applies platform render policy for managed daemon service environment values. */
import {
normalizeServiceEnvPlanKey,
type MutableServiceEnvPlan,
} from "./service-env-plan.js";
import { normalizeServiceEnvPlanKey, type MutableServiceEnvPlan } from "./service-env-plan.js";
import {
readManagedServiceEnvKeysFromEnvironment,
writeManagedServiceEnvKeysToEnvironment,
@@ -43,6 +40,7 @@ export function applyManagedServiceEnvRenderPolicy(params: {
managedServiceEnvKeys: string | undefined;
serviceEnvironment: Record<string, string | undefined>;
platform: NodeJS.Platform;
existingEnvironmentFileEnvironment: Record<string, string | undefined>;
stateDirDotEnvEnvironment: Record<string, string | undefined>;
configSecretRefEnvironment: Record<string, string | undefined>;
}): void {
@@ -58,6 +56,12 @@ export function applyManagedServiceEnvRenderPolicy(params: {
return;
}
if (launchAgent) {
addManagedServiceEnvEntries({
plan: params.plan,
entries: params.existingEnvironmentFileEnvironment,
managedKeys,
valueSource: "file",
});
addManagedServiceEnvEntries({
plan: params.plan,
entries: params.stateDirDotEnvEnvironment,