fix(daemon): preserve envfile auth provenance

This commit is contained in:
Peter Steinberger
2026-03-08 00:56:50 +00:00
parent ad052d661b
commit a2cb80b9c4
7 changed files with 120 additions and 12 deletions

View File

@@ -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<string, string>;
environmentValueSources?: Record<string, "inline" | "file">;
} | 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", () => {

View File

@@ -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"

View File

@@ -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", () => {

View File

@@ -14,6 +14,7 @@ export type GatewayServiceCommand = {
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string>;
environmentValueSources?: Record<string, "inline" | "file">;
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;
}

View File

@@ -27,6 +27,7 @@ export type GatewayServiceCommandConfig = {
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string>;
environmentValueSources?: Record<string, "inline" | "file">;
sourcePath?: string;
};

View File

@@ -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",
});
});
});

View File

@@ -65,7 +65,7 @@ export async function readSystemdServiceExecStart(
const content = await fs.readFile(unitPath, "utf8");
let execStart = "";
let workingDirectory = "";
const environment: Record<string, string> = {};
const inlineEnvironment: Record<string, string> = {};
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<string, string>,
source: "inline" | "file",
): Record<string, "inline" | "file"> {
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<Record<string, string>> {
}): Promise<{ environment: Record<string, string> }> {
const resolved: Record<string, string> = {};
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 = {