gateway: harden shared auth resolution across systemd, discord, and node host

This commit is contained in:
Josh Avant
2026-03-07 18:28:32 -06:00
committed by GitHub
parent a7f6e0a921
commit 25252ab5ab
28 changed files with 1498 additions and 255 deletions

View File

@@ -12,6 +12,7 @@ import { parseSystemdExecStart } from "./systemd-unit.js";
import {
isSystemdUserServiceAvailable,
parseSystemdShow,
readSystemdServiceExecStart,
restartSystemdService,
resolveSystemdUserUnitPath,
stopSystemdService,
@@ -42,6 +43,19 @@ const createWritableStreamMock = () => {
};
};
function pathLikeToString(pathname: unknown): string {
if (typeof pathname === "string") {
return pathname;
}
if (pathname instanceof URL) {
return pathname.pathname;
}
if (pathname instanceof Uint8Array) {
return Buffer.from(pathname).toString("utf8");
}
return "";
}
const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => {
const { write, stdout } = createWritableStreamMock();
await restartSystemdService({ stdout, env });
@@ -297,6 +311,173 @@ describe("parseSystemdExecStart", () => {
});
});
describe("readSystemdServiceExecStart", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("loads OPENCLAW_GATEWAY_TOKEN from EnvironmentFile", async () => {
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/.env",
].join("\n");
}
if (pathValue === "/home/test/.openclaw/.env") {
return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n";
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("env-file-token");
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
it("lets inline Environment override EnvironmentFile values", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/.env",
'Environment="OPENCLAW_GATEWAY_TOKEN=inline-token"',
].join("\n");
}
if (pathValue === "/home/test/.openclaw/.env") {
return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n";
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("inline-token");
});
it("ignores missing optional EnvironmentFile entries", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=-%h/.openclaw/missing.env",
].join("\n");
}
throw new Error(`missing: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]);
expect(command?.environment).toBeUndefined();
});
it("keeps parsing when non-optional EnvironmentFile entries are missing", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/missing.env",
].join("\n");
}
throw new Error(`missing: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]);
expect(command?.environment).toBeUndefined();
});
it("supports multiple EnvironmentFile entries and quoted paths", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
'EnvironmentFile=%h/.openclaw/first.env "%h/.openclaw/second env.env"',
].join("\n");
}
if (pathValue === "/home/test/.openclaw/first.env") {
return "OPENCLAW_GATEWAY_TOKEN=first-token\n";
}
if (pathValue === "/home/test/.openclaw/second env.env") {
return 'OPENCLAW_GATEWAY_PASSWORD="second password"\n';
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment).toEqual({
OPENCLAW_GATEWAY_TOKEN: "first-token",
OPENCLAW_GATEWAY_PASSWORD: "second password", // pragma: allowlist secret
});
});
it("resolves relative EnvironmentFile paths from the unit directory", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=./gateway.env ./override.env",
].join("\n");
}
if (pathValue.endsWith("/.config/systemd/user/gateway.env")) {
return [
"OPENCLAW_GATEWAY_TOKEN=relative-token",
"OPENCLAW_GATEWAY_PASSWORD=relative-password",
].join("\n");
}
if (pathValue.endsWith("/.config/systemd/user/override.env")) {
return "OPENCLAW_GATEWAY_TOKEN=override-token\n";
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment).toEqual({
OPENCLAW_GATEWAY_TOKEN: "override-token",
OPENCLAW_GATEWAY_PASSWORD: "relative-password", // pragma: allowlist secret
});
});
it("parses EnvironmentFile content with comments and quoted values", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/gateway.env",
].join("\n");
}
if (pathValue === "/home/test/.openclaw/gateway.env") {
return [
"# comment",
"; another comment",
'OPENCLAW_GATEWAY_TOKEN="quoted token"',
"OPENCLAW_GATEWAY_PASSWORD=quoted-password",
].join("\n");
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment).toEqual({
OPENCLAW_GATEWAY_TOKEN: "quoted token",
OPENCLAW_GATEWAY_PASSWORD: "quoted-password", // pragma: allowlist secret
});
});
});
describe("systemd service control", () => {
const assertMachineRestartArgs = (args: string[]) => {
expect(args).toEqual(["--machine", "debian@", "--user", "restart", "openclaw-gateway.service"]);

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { splitArgsPreservingQuotes } from "./arg-split.js";
import {
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
resolveGatewayServiceDescription,
@@ -65,6 +66,7 @@ export async function readSystemdServiceExecStart(
let execStart = "";
let workingDirectory = "";
const environment: Record<string, string> = {};
const environmentFileSpecs: string[] = [];
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
@@ -80,16 +82,30 @@ export async function readSystemdServiceExecStart(
if (parsed) {
environment[parsed.key] = parsed.value;
}
} else if (line.startsWith("EnvironmentFile=")) {
const raw = line.slice("EnvironmentFile=".length).trim();
if (raw) {
environmentFileSpecs.push(raw);
}
}
}
if (!execStart) {
return null;
}
const environmentFromFiles = await resolveSystemdEnvironmentFiles({
environmentFileSpecs,
env,
unitPath,
});
const mergedEnvironment = {
...environmentFromFiles,
...environment,
};
const programArguments = parseSystemdExecStart(execStart);
return {
programArguments,
...(workingDirectory ? { workingDirectory } : {}),
...(Object.keys(environment).length > 0 ? { environment } : {}),
...(Object.keys(mergedEnvironment).length > 0 ? { environment: mergedEnvironment } : {}),
sourcePath: unitPath,
};
} catch {
@@ -97,6 +113,89 @@ export async function readSystemdServiceExecStart(
}
}
function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string {
// Support the common unit-specifier used in user services.
return input.replaceAll("%h", toPosixPath(resolveHomeDir(env)));
}
function parseEnvironmentFileSpecs(raw: string): string[] {
return splitArgsPreservingQuotes(raw, { escapeMode: "backslash" })
.map((entry) => entry.trim())
.filter(Boolean);
}
function parseEnvironmentFileLine(rawLine: string): { key: string; value: string } | null {
const trimmed = rawLine.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) {
return null;
}
const eq = trimmed.indexOf("=");
if (eq <= 0) {
return null;
}
const key = trimmed.slice(0, eq).trim();
if (!key) {
return null;
}
let value = trimmed.slice(eq + 1).trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
return { key, value };
}
async function readSystemdEnvironmentFile(pathname: string): Promise<Record<string, string>> {
const environment: Record<string, string> = {};
const content = await fs.readFile(pathname, "utf8");
for (const rawLine of content.split(/\r?\n/)) {
const parsed = parseEnvironmentFileLine(rawLine);
if (!parsed) {
continue;
}
environment[parsed.key] = parsed.value;
}
return environment;
}
async function resolveSystemdEnvironmentFiles(params: {
environmentFileSpecs: string[];
env: GatewayServiceEnv;
unitPath: string;
}): Promise<Record<string, string>> {
const resolved: Record<string, string> = {};
if (params.environmentFileSpecs.length === 0) {
return resolved;
}
const unitDir = path.posix.dirname(params.unitPath);
for (const specRaw of params.environmentFileSpecs) {
for (const token of parseEnvironmentFileSpecs(specRaw)) {
const optional = token.startsWith("-");
const pathnameRaw = optional ? token.slice(1).trim() : token;
if (!pathnameRaw) {
continue;
}
const expanded = expandSystemdSpecifier(pathnameRaw, params.env);
const pathname = path.posix.isAbsolute(expanded)
? expanded
: path.posix.resolve(unitDir, expanded);
try {
const fromFile = await readSystemdEnvironmentFile(pathname);
Object.assign(resolved, fromFile);
} catch {
// Keep service auditing resilient even when env files are unavailable
// in the current runtime context. Both optional and non-optional
// EnvironmentFile entries are skipped gracefully for diagnostics.
continue;
}
}
}
return resolved;
}
export type SystemdServiceInfo = {
activeState?: string;
subState?: string;