diff --git a/src/cli/daemon-cli/start-repair.test.ts b/src/cli/daemon-cli/start-repair.test.ts new file mode 100644 index 000000000000..4de705edd82b --- /dev/null +++ b/src/cli/daemon-cli/start-repair.test.ts @@ -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; + 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 { + const [firstArg] = buildGatewayInstallPlanMock.mock.calls[0] ?? []; + if (!firstArg) { + throw new Error("expected first install plan call"); + } + return firstArg as Record; +} + +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" }, + }), + ); + }); +}); diff --git a/src/cli/daemon-cli/start-repair.ts b/src/cli/daemon-cli/start-repair.ts index dc8a7dce7991..d4896cf9f927 100644 --- a/src/cli/daemon-cli/start-repair.ts +++ b/src/cli/daemon-cli/start-repair.ts @@ -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); diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index b01b8de99d0b..efe458c0c13d 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -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: { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index c34e393df796..43066291a2da 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -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; config?: OpenClawConfig; configContainsSecretRef: boolean; stateDirDotEnvEnvironment: Record; warn?: DaemonInstallWarnFn; -}): Record { +}): { keys: string[]; environment: Record } { + const keys = new Set(); + const environment: Record = {}; if (!params.config || !params.configContainsSecretRef) { - return {}; + return { keys: [], environment }; } - const entries: Record = {}; 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 | undefined; + existingEnvironmentValueSources?: Record< + string, + GatewayServiceEnvironmentValueSource | undefined + >; + configSecretRefKeys: ReadonlySet; +}): Record { + if (!params.existingEnvironment || params.configSecretRefKeys.size === 0) { + return {}; + } + const preserved: Record = {}; + 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, shadowEntries: Array>, @@ -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, }); diff --git a/src/daemon/service-env-render-policy.ts b/src/daemon/service-env-render-policy.ts index 9098983e48bd..3e318ddaa071 100644 --- a/src/daemon/service-env-render-policy.ts +++ b/src/daemon/service-env-render-policy.ts @@ -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; platform: NodeJS.Platform; + existingEnvironmentFileEnvironment: Record; stateDirDotEnvEnvironment: Record; configSecretRefEnvironment: Record; }): 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,