From a2cb80b9c4bee9bf3181a5c14d83106333b4bee2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 00:56:50 +0000 Subject: [PATCH] fix(daemon): preserve envfile auth provenance --- src/commands/doctor-gateway-services.test.ts | 52 ++++++++++++++++++++ src/commands/doctor-gateway-services.ts | 5 +- src/daemon/service-audit.test.ts | 24 +++++++++ src/daemon/service-audit.ts | 13 ++++- src/daemon/service-types.ts | 1 + src/daemon/systemd.test.ts | 9 +++- src/daemon/systemd.ts | 28 ++++++++--- 7 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 8158613efd9..66dd090f2b8 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -39,6 +39,15 @@ vi.mock("../daemon/runtime-paths.js", () => ({ vi.mock("../daemon/service-audit.js", () => ({ auditGatewayServiceConfig: mocks.auditGatewayServiceConfig, needsNodeRuntimeMigration: vi.fn(() => false), + readEmbeddedGatewayToken: ( + command: { + environment?: Record; + environmentValueSources?: Record; + } | null, + ) => + command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file" + ? undefined + : command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined, SERVICE_AUDIT_CODES: { gatewayEntrypointMismatch: "gateway-entrypoint-mismatch", }, @@ -299,6 +308,49 @@ describe("maybeRepairGatewayServiceConfig", () => { }, ); }); + + it("does not persist EnvironmentFile-backed service tokens into config", async () => { + await withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: undefined, + CLAWDBOT_GATEWAY_TOKEN: undefined, + }, + async () => { + mocks.readCommand.mockResolvedValue({ + programArguments: gatewayProgramArguments, + environment: { + OPENCLAW_GATEWAY_TOKEN: "env-file-token", + }, + environmentValueSources: { + OPENCLAW_GATEWAY_TOKEN: "file", + }, + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: false, + issues: [], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + programArguments: gatewayProgramArguments, + workingDirectory: "/tmp", + environment: {}, + }); + mocks.install.mockResolvedValue(undefined); + + const cfg: OpenClawConfig = { + gateway: {}, + }; + + await runRepair(cfg); + + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + }), + ); + }, + ); + }); }); describe("maybeScanExtraGatewayServices", () => { diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 85f735baf8b..68adf9374c6 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -15,6 +15,7 @@ import { renderSystemNodeWarning, resolveSystemNodeInfo } from "../daemon/runtim import { auditGatewayServiceConfig, needsNodeRuntimeMigration, + readEmbeddedGatewayToken, SERVICE_AUDIT_CODES, } from "../daemon/service-audit.js"; import { resolveGatewayService } from "../daemon/service.js"; @@ -230,7 +231,7 @@ export async function maybeRepairGatewayServiceConfig( command, expectedGatewayToken, }); - const serviceToken = command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim(); + const serviceToken = readEmbeddedGatewayToken(command); if (tokenRefConfigured && serviceToken) { audit.issues.push({ code: SERVICE_AUDIT_CODES.gatewayTokenMismatch, @@ -316,7 +317,7 @@ export async function maybeRepairGatewayServiceConfig( if (!repair) { return; } - const serviceEmbeddedToken = command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined; + const serviceEmbeddedToken = readEmbeddedGatewayToken(command); const gatewayTokenForRepair = expectedGatewayToken ?? serviceEmbeddedToken; const configuredGatewayToken = typeof cfg.gateway?.auth?.token === "string" diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index ebcdf5d643d..ffdd0fa526d 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -126,6 +126,30 @@ describe("auditGatewayServiceConfig", () => { audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch), ).toBe(false); }); + + it("does not treat EnvironmentFile-backed tokens as embedded", async () => { + const audit = await auditGatewayServiceConfig({ + env: { HOME: "/tmp" }, + platform: "linux", + expectedGatewayToken: "new-token", + command: { + programArguments: ["/usr/bin/node", "gateway"], + environment: { + PATH: "/usr/local/bin:/usr/bin:/bin", + OPENCLAW_GATEWAY_TOKEN: "old-token", + }, + environmentValueSources: { + OPENCLAW_GATEWAY_TOKEN: "file", + }, + }, + }); + expect( + audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenEmbedded), + ).toBe(false); + expect( + audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch), + ).toBe(false); + }); }); describe("checkTokenDrift", () => { diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index 6caa320d6ac..61f5c94f683 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -14,6 +14,7 @@ export type GatewayServiceCommand = { programArguments: string[]; workingDirectory?: string; environment?: Record; + environmentValueSources?: Record; sourcePath?: string; } | null; @@ -209,7 +210,7 @@ function auditGatewayToken( issues: ServiceConfigIssue[], expectedGatewayToken?: string, ) { - const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim(); + const serviceToken = readEmbeddedGatewayToken(command); if (!serviceToken) { return; } @@ -232,6 +233,16 @@ function auditGatewayToken( }); } +export function readEmbeddedGatewayToken(command: GatewayServiceCommand): string | undefined { + if (!command) { + return undefined; + } + if (command.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file") { + return undefined; + } + return command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined; +} + function getPathModule(platform: NodeJS.Platform) { return platform === "win32" ? path.win32 : path.posix; } diff --git a/src/daemon/service-types.ts b/src/daemon/service-types.ts index 38f3efaee18..ae7d8d1a28f 100644 --- a/src/daemon/service-types.ts +++ b/src/daemon/service-types.ts @@ -27,6 +27,7 @@ export type GatewayServiceCommandConfig = { programArguments: string[]; workingDirectory?: string; environment?: Record; + environmentValueSources?: Record; sourcePath?: string; }; diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 46506bf0ab1..825f97e4122 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -460,7 +460,7 @@ describe("readSystemdServiceExecStart", () => { expect(readFileSpy).toHaveBeenCalledTimes(2); }); - it("lets inline Environment override EnvironmentFile values", async () => { + it("lets EnvironmentFile override inline Environment values", async () => { vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { const pathValue = pathLikeToString(pathname); if (pathValue.endsWith("/openclaw-gateway.service")) { @@ -478,7 +478,8 @@ describe("readSystemdServiceExecStart", () => { }); const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); - expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("inline-token"); + expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("env-file-token"); + expect(command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN).toBe("file"); }); it("ignores missing optional EnvironmentFile entries", async () => { @@ -598,6 +599,10 @@ describe("readSystemdServiceExecStart", () => { OPENCLAW_GATEWAY_TOKEN: "quoted token", OPENCLAW_GATEWAY_PASSWORD: "quoted-password", // pragma: allowlist secret }); + expect(command?.environmentValueSources).toEqual({ + OPENCLAW_GATEWAY_TOKEN: "file", + OPENCLAW_GATEWAY_PASSWORD: "file", + }); }); }); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 5772518808e..889db4a07fc 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -65,7 +65,7 @@ export async function readSystemdServiceExecStart( const content = await fs.readFile(unitPath, "utf8"); let execStart = ""; let workingDirectory = ""; - const environment: Record = {}; + const inlineEnvironment: Record = {}; const environmentFileSpecs: string[] = []; for (const rawLine of content.split("\n")) { const line = rawLine.trim(); @@ -80,7 +80,7 @@ export async function readSystemdServiceExecStart( const raw = line.slice("Environment=".length).trim(); const parsed = parseSystemdEnvAssignment(raw); if (parsed) { - environment[parsed.key] = parsed.value; + inlineEnvironment[parsed.key] = parsed.value; } } else if (line.startsWith("EnvironmentFile=")) { const raw = line.slice("EnvironmentFile=".length).trim(); @@ -98,14 +98,21 @@ export async function readSystemdServiceExecStart( unitPath, }); const mergedEnvironment = { - ...environmentFromFiles, - ...environment, + ...inlineEnvironment, + ...environmentFromFiles.environment, + }; + const mergedEnvironmentSources = { + ...buildEnvironmentValueSources(inlineEnvironment, "inline"), + ...buildEnvironmentValueSources(environmentFromFiles.environment, "file"), }; const programArguments = parseSystemdExecStart(execStart); return { programArguments, ...(workingDirectory ? { workingDirectory } : {}), ...(Object.keys(mergedEnvironment).length > 0 ? { environment: mergedEnvironment } : {}), + ...(Object.keys(mergedEnvironmentSources).length > 0 + ? { environmentValueSources: mergedEnvironmentSources } + : {}), sourcePath: unitPath, }; } catch { @@ -113,6 +120,13 @@ export async function readSystemdServiceExecStart( } } +function buildEnvironmentValueSources( + environment: Record, + source: "inline" | "file", +): Record { + return Object.fromEntries(Object.keys(environment).map((key) => [key, source])); +} + function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string { // Support the common unit-specifier used in user services. return input.replaceAll("%h", toPosixPath(resolveHomeDir(env))); @@ -165,10 +179,10 @@ async function resolveSystemdEnvironmentFiles(params: { environmentFileSpecs: string[]; env: GatewayServiceEnv; unitPath: string; -}): Promise> { +}): Promise<{ environment: Record }> { const resolved: Record = {}; if (params.environmentFileSpecs.length === 0) { - return resolved; + return { environment: resolved }; } const unitDir = path.posix.dirname(params.unitPath); for (const specRaw of params.environmentFileSpecs) { @@ -193,7 +207,7 @@ async function resolveSystemdEnvironmentFiles(params: { } } } - return resolved; + return { environment: resolved }; } export type SystemdServiceInfo = {