mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-30 01:06:11 +00:00
refactor: extract gateway install token helpers
This commit is contained in:
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user