mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 14:43:57 +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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user