refactor: extract gateway install token helpers

This commit is contained in:
Peter Steinberger
2026-03-22 22:05:19 -07:00
parent c15282062f
commit 8791aaae2b
4 changed files with 172 additions and 176 deletions

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeStateDirDotEnv } from "../config/test-helpers.js";
const mocks = vi.hoisted(() => ({
loadAuthProfileStoreForSecretsRuntime: vi.fn(),
@@ -33,7 +34,6 @@ vi.mock("../daemon/service-env.js", () => ({
import {
buildGatewayInstallPlan,
gatewayInstallErrorHint,
readStateDirDotEnvVars,
resolveGatewayDevMode,
} from "./daemon-install-helpers.js";
@@ -342,68 +342,9 @@ describe("buildGatewayInstallPlan", () => {
});
});
describe("readStateDirDotEnvVars", () => {
let tmpDir: string;
function writeDotEnv(content: string): void {
const ocDir = path.join(tmpDir, ".openclaw");
fs.mkdirSync(ocDir, { recursive: true });
fs.writeFileSync(path.join(ocDir, ".env"), content, "utf8");
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-dotenv-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("reads key-value pairs from state dir .env file", () => {
writeDotEnv("BRAVE_API_KEY=BSA-test-key\nDISCORD_BOT_TOKEN=discord-tok\n");
const vars = readStateDirDotEnvVars({ HOME: tmpDir });
expect(vars.BRAVE_API_KEY).toBe("BSA-test-key");
expect(vars.DISCORD_BOT_TOKEN).toBe("discord-tok");
});
it("returns empty record when .env file is missing", () => {
const vars = readStateDirDotEnvVars({ HOME: tmpDir });
expect(vars).toEqual({});
});
it("drops dangerous env vars like NODE_OPTIONS", () => {
writeDotEnv("NODE_OPTIONS=--require /tmp/evil.js\nSAFE_KEY=safe\n");
const vars = readStateDirDotEnvVars({ HOME: tmpDir });
expect(vars.NODE_OPTIONS).toBeUndefined();
expect(vars.SAFE_KEY).toBe("safe");
});
it("drops empty and whitespace-only values", () => {
writeDotEnv("EMPTY=\nBLANK= \nVALID=ok\n");
const vars = readStateDirDotEnvVars({ HOME: tmpDir });
expect(vars.EMPTY).toBeUndefined();
expect(vars.BLANK).toBeUndefined();
expect(vars.VALID).toBe("ok");
});
it("respects OPENCLAW_STATE_DIR override", () => {
const customDir = path.join(tmpDir, "custom-state");
fs.mkdirSync(customDir, { recursive: true });
fs.writeFileSync(path.join(customDir, ".env"), "CUSTOM_KEY=from-override\n", "utf8");
const vars = readStateDirDotEnvVars({ OPENCLAW_STATE_DIR: customDir });
expect(vars.CUSTOM_KEY).toBe("from-override");
});
});
describe("buildGatewayInstallPlan — dotenv merge", () => {
let tmpDir: string;
function writeDotEnv(content: string): void {
const ocDir = path.join(tmpDir, ".openclaw");
fs.mkdirSync(ocDir, { recursive: true });
fs.writeFileSync(path.join(ocDir, ".env"), content, "utf8");
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-plan-dotenv-"));
});
@@ -413,7 +354,9 @@ describe("buildGatewayInstallPlan — dotenv merge", () => {
});
it("merges .env file vars into the install plan", async () => {
writeDotEnv("BRAVE_API_KEY=BSA-from-env\nOPENROUTER_API_KEY=or-key\n");
await writeStateDirDotEnv("BRAVE_API_KEY=BSA-from-env\nOPENROUTER_API_KEY=or-key\n", {
stateDir: path.join(tmpDir, ".openclaw"),
});
mockNodeGatewayPlanFixture({ serviceEnvironment: { OPENCLAW_PORT: "3000" } });
const plan = await buildGatewayInstallPlan({
@@ -428,7 +371,9 @@ describe("buildGatewayInstallPlan — dotenv merge", () => {
});
it("config env vars override .env file vars", async () => {
writeDotEnv("MY_KEY=from-dotenv\n");
await writeStateDirDotEnv("MY_KEY=from-dotenv\n", {
stateDir: path.join(tmpDir, ".openclaw"),
});
mockNodeGatewayPlanFixture({ serviceEnvironment: {} });
const plan = await buildGatewayInstallPlan({
@@ -448,7 +393,9 @@ describe("buildGatewayInstallPlan — dotenv merge", () => {
});
it("service env overrides .env file vars", async () => {
writeDotEnv("HOME=/from-dotenv\n");
await writeStateDirDotEnv("HOME=/from-dotenv\n", {
stateDir: path.join(tmpDir, ".openclaw"),
});
mockNodeGatewayPlanFixture({
serviceEnvironment: { HOME: "/from-service" },
});

View File

@@ -1,22 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import dotenv from "dotenv";
import {
loadAuthProfileStoreForSecretsRuntime,
type AuthProfileStore,
} from "../agents/auth-profiles.js";
import { formatCliCommand } from "../cli/command-format.js";
import { collectConfigServiceEnvVars } from "../config/env-vars.js";
import { resolveStateDir } from "../config/paths.js";
import { collectDurableServiceEnvVars } from "../config/state-dir-dotenv.js";
import type { OpenClawConfig } from "../config/types.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import {
isDangerousHostEnvOverrideVarName,
isDangerousHostEnvVarName,
normalizeEnvVarKey,
} from "../infra/host-env-security.js";
import {
emitDaemonInstallRuntimeWarning,
resolveDaemonInstallRuntimeInputs,
@@ -27,45 +18,6 @@ import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export { resolveGatewayDevMode } from "./daemon-install-plan.shared.js";
/**
* Read and parse `~/.openclaw/.env` (or `$OPENCLAW_STATE_DIR/.env`), returning
* a filtered record of key-value pairs suitable for embedding in a service
* environment (LaunchAgent plist, systemd unit, Scheduled Task).
*
* Security: dangerous host env vars (NODE_OPTIONS, LD_PRELOAD, etc.) are
* dropped, matching the same policy applied to config env vars.
*/
export function readStateDirDotEnvVars(
env: Record<string, string | undefined>,
): Record<string, string> {
const stateDir = resolveStateDir(env as NodeJS.ProcessEnv);
const dotEnvPath = path.join(stateDir, ".env");
let content: string;
try {
content = fs.readFileSync(dotEnvPath, "utf8");
} catch {
return {};
}
const parsed = dotenv.parse(content);
const entries: Record<string, string> = {};
for (const [rawKey, value] of Object.entries(parsed)) {
if (!value?.trim()) {
continue;
}
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (!key) {
continue;
}
if (isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)) {
continue;
}
entries[key] = value;
}
return entries;
}
export type GatewayInstallPlan = {
programArguments: string[];
workingDirectory?: string;
@@ -99,6 +51,26 @@ function collectAuthProfileServiceEnvVars(params: {
return entries;
}
function buildGatewayInstallEnvironment(params: {
env: Record<string, string | undefined>;
config?: OpenClawConfig;
authStore?: AuthProfileStore;
serviceEnvironment: Record<string, string | undefined>;
}): Record<string, string | undefined> {
const environment: Record<string, string | undefined> = {
...collectDurableServiceEnvVars({
env: params.env,
config: params.config,
}),
...collectAuthProfileServiceEnvVars({
env: params.env,
authStore: params.authStore,
}),
};
Object.assign(environment, params.serviceEnvironment);
return environment;
}
export async function buildGatewayInstallPlan(params: {
env: Record<string, string | undefined>;
port: number;
@@ -146,17 +118,16 @@ export async function buildGatewayInstallPlan(params: {
// 2. Config env vars (openclaw.json env.vars + inline keys)
// 3. Auth-profile env refs (credential store → env var lookups)
// 4. Service environment (HOME, PATH, OPENCLAW_* — highest)
const environment: Record<string, string | undefined> = {
...readStateDirDotEnvVars(params.env),
...collectConfigServiceEnvVars(params.config),
...collectAuthProfileServiceEnvVars({
return {
programArguments,
workingDirectory,
environment: buildGatewayInstallEnvironment({
env: params.env,
config: params.config,
authStore: params.authStore,
serviceEnvironment,
}),
};
Object.assign(environment, serviceEnvironment);
return { programArguments, workingDirectory, environment };
}
export function gatewayInstallErrorHint(platform = process.platform): string {

View File

@@ -257,6 +257,38 @@ describe("resolveGatewayInstallToken", () => {
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("passes the install env through to gateway auth resolution", async () => {
const env = {
OPENCLAW_GATEWAY_PASSWORD: "dotenv-password", // pragma: allowlist secret
} as NodeJS.ProcessEnv;
shouldRequireGatewayTokenForInstallMock.mockReturnValue(false);
resolveGatewayAuthMock.mockReturnValue({
mode: "password",
token: undefined,
password: undefined,
allowTailscale: false,
});
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: {} },
} as OpenClawConfig,
env,
autoGenerateWhenMissing: true,
persistGeneratedToken: true,
});
expect(resolveGatewayAuthMock).toHaveBeenCalledWith({
authConfig: {},
env,
tailscaleMode: "off",
});
expect(result.token).toBeUndefined();
expect(result.unavailableReason).toBeUndefined();
expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false);
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("skips token SecretRef resolution when token auth is not required", async () => {
const tokenRef = { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" };
resolveSecretInputRefMock.mockReturnValue({ ref: tokenRef });

View File

@@ -24,6 +24,91 @@ export type GatewayInstallTokenResolution = {
warnings: string[];
};
function resolveConfiguredGatewayInstallToken(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
explicitToken?: string;
tokenRef: unknown;
}): string | undefined {
const configToken =
params.tokenRef || typeof params.config.gateway?.auth?.token !== "string"
? undefined
: params.config.gateway.auth.token.trim() || undefined;
const explicitToken = params.explicitToken?.trim() || undefined;
const envToken = readGatewayTokenEnv(params.env);
return explicitToken || configToken || (params.tokenRef ? undefined : envToken);
}
async function validateGatewayInstallTokenSecretRef(params: {
tokenRef: NonNullable<ReturnType<typeof resolveSecretInputRef>["ref"]>;
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<string | undefined> {
try {
const resolved = await resolveSecretRefValues([params.tokenRef], {
config: params.config,
env: params.env,
});
const value = resolved.get(secretRefKey(params.tokenRef));
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.token resolved to an empty or non-string value.");
}
return undefined;
} catch (err) {
return `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`;
}
}
async function maybePersistAutoGeneratedGatewayInstallToken(params: {
token: string;
config: OpenClawConfig;
warnings: string[];
}): Promise<string | undefined> {
try {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
params.warnings.push(
"Warning: config file exists but is invalid; skipping token persistence.",
);
return params.token;
}
const baseConfig = snapshot.exists ? snapshot.config : {};
const existingTokenRef = resolveSecretInputRef({
value: baseConfig.gateway?.auth?.token,
defaults: baseConfig.secrets?.defaults,
}).ref;
const baseConfigToken =
existingTokenRef || typeof baseConfig.gateway?.auth?.token !== "string"
? undefined
: baseConfig.gateway.auth.token.trim() || undefined;
if (!existingTokenRef && !baseConfigToken) {
await writeConfigFile({
...baseConfig,
gateway: {
...baseConfig.gateway,
auth: {
...baseConfig.gateway?.auth,
mode: baseConfig.gateway?.auth?.mode ?? "token",
token: params.token,
},
},
});
return params.token;
}
if (baseConfigToken) {
return baseConfigToken;
}
params.warnings.push(
"Warning: gateway.auth.token is SecretRef-managed; skipping plaintext token persistence.",
);
return undefined;
} catch (err) {
params.warnings.push(`Warning: could not persist token to config: ${String(err)}`);
return params.token;
}
}
function formatAmbiguousGatewayAuthModeReason(): string {
return [
"gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset.",
@@ -41,12 +126,6 @@ export async function resolveGatewayInstallToken(
defaults: cfg.secrets?.defaults,
}).ref;
const tokenRefConfigured = Boolean(tokenRef);
const configToken =
tokenRef || typeof cfg.gateway?.auth?.token !== "string"
? undefined
: cfg.gateway.auth.token.trim() || undefined;
const explicitToken = options.explicitToken?.trim() || undefined;
const envToken = readGatewayTokenEnv(options.env);
if (hasAmbiguousGatewayAuthModeConfig(cfg)) {
return {
@@ -59,29 +138,30 @@ export async function resolveGatewayInstallToken(
const resolvedAuth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
env: options.env,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
});
const needsToken =
shouldRequireGatewayTokenForInstall(cfg, options.env) && !resolvedAuth.allowTailscale;
let token: string | undefined = explicitToken || configToken || (tokenRef ? undefined : envToken);
let token = resolveConfiguredGatewayInstallToken({
config: cfg,
env: options.env,
explicitToken: options.explicitToken,
tokenRef,
});
let unavailableReason: string | undefined;
if (tokenRef && !token && needsToken) {
try {
const resolved = await resolveSecretRefValues([tokenRef], {
config: cfg,
env: options.env,
});
const value = resolved.get(secretRefKey(tokenRef));
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.token resolved to an empty or non-string value.");
}
unavailableReason = await validateGatewayInstallTokenSecretRef({
tokenRef,
config: cfg,
env: options.env,
});
if (!unavailableReason) {
warnings.push(
"gateway.auth.token is SecretRef-managed; install will not persist a resolved token in service environment. Ensure the SecretRef is resolvable in the daemon runtime context.",
);
} catch (err) {
unavailableReason = `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`;
}
}
@@ -96,45 +176,11 @@ export async function resolveGatewayInstallToken(
);
if (persistGeneratedToken) {
// Persist token in config so daemon and CLI share a stable credential source.
try {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
warnings.push("Warning: config file exists but is invalid; skipping token persistence.");
} else {
const baseConfig = snapshot.exists ? snapshot.config : {};
const existingTokenRef = resolveSecretInputRef({
value: baseConfig.gateway?.auth?.token,
defaults: baseConfig.secrets?.defaults,
}).ref;
const baseConfigToken =
existingTokenRef || typeof baseConfig.gateway?.auth?.token !== "string"
? undefined
: baseConfig.gateway.auth.token.trim() || undefined;
if (!existingTokenRef && !baseConfigToken) {
await writeConfigFile({
...baseConfig,
gateway: {
...baseConfig.gateway,
auth: {
...baseConfig.gateway?.auth,
mode: baseConfig.gateway?.auth?.mode ?? "token",
token,
},
},
});
} else if (baseConfigToken) {
token = baseConfigToken;
} else {
token = undefined;
warnings.push(
"Warning: gateway.auth.token is SecretRef-managed; skipping plaintext token persistence.",
);
}
}
} catch (err) {
warnings.push(`Warning: could not persist token to config: ${String(err)}`);
}
token = await maybePersistAutoGeneratedGatewayInstallToken({
token,
config: cfg,
warnings,
});
}
}