mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 06:01:34 +00:00
* fix(cron): run legacy cron store migration in gateway fast path * fix(cli): run gateway startup migrations * fix(gateway): guard startup migrations and config selection * fix(gateway): reconcile final startup environment * fix(gateway): preserve guarded startup env semantics * fix(gateway): guard service-mode recovery candidates * fix(config): reconcile normalized env precedence * fix(cli): clear replaced proxy signal handlers * fix(gateway): reject invalid final config * test(gateway): cover invalid future config reset guard * test(cli): remove unused recovery state
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
// Covers config path resolution across env, home, and agent roots.
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { withTempDir } from "../test-helpers/temp-dir.js";
|
|
import {
|
|
CONFIG_PATH,
|
|
DEFAULT_GATEWAY_PORT,
|
|
isNixMode,
|
|
normalizeStateDirEnv,
|
|
pinRuntimePaths,
|
|
resolveDefaultConfigCandidates,
|
|
resolveConfigPathCandidate,
|
|
resolveConfigPath,
|
|
resolveGatewayPort,
|
|
resolveIncludeRoots,
|
|
resolveOAuthDir,
|
|
resolveOAuthPath,
|
|
resolveStateDir,
|
|
STATE_DIR,
|
|
} from "./paths.js";
|
|
|
|
function envWith(overrides: Record<string, string | undefined>): NodeJS.ProcessEnv {
|
|
return { ...overrides };
|
|
}
|
|
|
|
describe("oauth paths", () => {
|
|
it("prefers OPENCLAW_OAUTH_DIR over OPENCLAW_STATE_DIR", () => {
|
|
const env = {
|
|
OPENCLAW_OAUTH_DIR: "/custom/oauth",
|
|
OPENCLAW_STATE_DIR: "/custom/state",
|
|
} as NodeJS.ProcessEnv;
|
|
|
|
expect(resolveOAuthDir(env, "/custom/state")).toBe(path.resolve("/custom/oauth"));
|
|
expect(resolveOAuthPath(env, "/custom/state")).toBe(
|
|
path.join(path.resolve("/custom/oauth"), "oauth.json"),
|
|
);
|
|
});
|
|
|
|
it("derives oauth path from OPENCLAW_STATE_DIR when unset", () => {
|
|
const env = {
|
|
OPENCLAW_STATE_DIR: "/custom/state",
|
|
} as NodeJS.ProcessEnv;
|
|
|
|
expect(resolveOAuthDir(env, "/custom/state")).toBe(path.join("/custom/state", "credentials"));
|
|
expect(resolveOAuthPath(env, "/custom/state")).toBe(
|
|
path.join("/custom/state", "credentials", "oauth.json"),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("gateway port resolution", () => {
|
|
it("prefers numeric env values over config", () => {
|
|
expect(
|
|
resolveGatewayPort({ gateway: { port: 19002 } }, envWith({ OPENCLAW_GATEWAY_PORT: "19001" })),
|
|
).toBe(19001);
|
|
});
|
|
|
|
it("accepts Compose-style IPv4 host publish values from env", () => {
|
|
expect(
|
|
resolveGatewayPort(
|
|
{ gateway: { port: 19002 } },
|
|
envWith({ OPENCLAW_GATEWAY_PORT: "127.0.0.1:18789" }),
|
|
),
|
|
).toBe(18789);
|
|
});
|
|
|
|
it("accepts Compose-style IPv6 host publish values from env", () => {
|
|
expect(
|
|
resolveGatewayPort(
|
|
{ gateway: { port: 19002 } },
|
|
envWith({ OPENCLAW_GATEWAY_PORT: "[::1]:28789" }),
|
|
),
|
|
).toBe(28789);
|
|
});
|
|
|
|
it("ignores the legacy env name and falls back to config", () => {
|
|
expect(
|
|
resolveGatewayPort(
|
|
{ gateway: { port: 19002 } },
|
|
envWith({ CLAWDBOT_GATEWAY_PORT: "127.0.0.1:18789" }),
|
|
),
|
|
).toBe(19002);
|
|
});
|
|
|
|
it("falls back to config when the Compose-style suffix is invalid", () => {
|
|
expect(
|
|
resolveGatewayPort(
|
|
{ gateway: { port: 19003 } },
|
|
envWith({ OPENCLAW_GATEWAY_PORT: "127.0.0.1:not-a-port" }),
|
|
),
|
|
).toBe(19003);
|
|
});
|
|
|
|
it("falls back to config when env ports exceed TCP bounds", () => {
|
|
expect(
|
|
resolveGatewayPort({ gateway: { port: 19003 } }, envWith({ OPENCLAW_GATEWAY_PORT: "65536" })),
|
|
).toBe(19003);
|
|
expect(
|
|
resolveGatewayPort(
|
|
{ gateway: { port: 19004 } },
|
|
envWith({ OPENCLAW_GATEWAY_PORT: "127.0.0.1:65536" }),
|
|
),
|
|
).toBe(19004);
|
|
expect(
|
|
resolveGatewayPort(
|
|
{ gateway: { port: 19005 } },
|
|
envWith({ OPENCLAW_GATEWAY_PORT: "[::1]:65536" }),
|
|
),
|
|
).toBe(19005);
|
|
});
|
|
|
|
it("falls back when malformed IPv6 inputs do not provide an explicit port", () => {
|
|
expect(
|
|
resolveGatewayPort({ gateway: { port: 19003 } }, envWith({ OPENCLAW_GATEWAY_PORT: "::1" })),
|
|
).toBe(19003);
|
|
expect(resolveGatewayPort({}, envWith({ OPENCLAW_GATEWAY_PORT: "2001:db8::1" }))).toBe(
|
|
DEFAULT_GATEWAY_PORT,
|
|
);
|
|
});
|
|
|
|
it("falls back to the default port when env is invalid and config is unset", () => {
|
|
expect(resolveGatewayPort({}, envWith({ OPENCLAW_GATEWAY_PORT: "127.0.0.1:not-a-port" }))).toBe(
|
|
DEFAULT_GATEWAY_PORT,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("state + config path candidates", () => {
|
|
function expectOpenClawHomeDefaults(env: NodeJS.ProcessEnv): void {
|
|
const configuredHome = env.OPENCLAW_HOME;
|
|
if (!configuredHome) {
|
|
throw new Error("OPENCLAW_HOME must be set for this assertion helper");
|
|
}
|
|
const resolvedHome = path.resolve(configuredHome);
|
|
expect(resolveStateDir(env)).toBe(path.join(resolvedHome, ".openclaw"));
|
|
|
|
const candidates = resolveDefaultConfigCandidates(env);
|
|
expect(candidates[0]).toBe(path.join(resolvedHome, ".openclaw", "openclaw.json"));
|
|
}
|
|
|
|
it("uses OPENCLAW_STATE_DIR when set", () => {
|
|
const env = {
|
|
OPENCLAW_STATE_DIR: "/new/state",
|
|
} as NodeJS.ProcessEnv;
|
|
|
|
expect(resolveStateDir(env, () => "/home/test")).toBe(path.resolve("/new/state"));
|
|
});
|
|
|
|
it("normalizes relative OPENCLAW_STATE_DIR overrides to absolute paths", () => {
|
|
const env = {
|
|
OPENCLAW_STATE_DIR: ".",
|
|
OPENCLAW_HOME: "/srv/openclaw-home",
|
|
} as NodeJS.ProcessEnv;
|
|
|
|
normalizeStateDirEnv(env);
|
|
|
|
expect(env.OPENCLAW_STATE_DIR).toBe(path.resolve("."));
|
|
});
|
|
|
|
it("pins a relative state-dir override before later resolution", () => {
|
|
const env = {
|
|
OPENCLAW_STATE_DIR: "relative-state",
|
|
OPENCLAW_HOME: "/srv/openclaw-home",
|
|
} as NodeJS.ProcessEnv;
|
|
|
|
normalizeStateDirEnv(env);
|
|
const normalized = env.OPENCLAW_STATE_DIR;
|
|
|
|
expect(normalized).toBe(path.resolve("relative-state"));
|
|
expect(resolveStateDir(env, () => "/srv/other-home")).toBe(normalized);
|
|
});
|
|
|
|
it("re-pins exported runtime paths after startup environment selection", () => {
|
|
const originalConfigPath = CONFIG_PATH;
|
|
const originalNixMode = isNixMode;
|
|
const originalStateDir = STATE_DIR;
|
|
const selectedStateDir = path.resolve("/tmp/openclaw-selected-runtime-state");
|
|
const selectedConfigPath = path.join(selectedStateDir, "selected.json");
|
|
try {
|
|
const pinned = pinRuntimePaths({
|
|
OPENCLAW_CONFIG_PATH: selectedConfigPath,
|
|
OPENCLAW_NIX_MODE: "1",
|
|
OPENCLAW_STATE_DIR: selectedStateDir,
|
|
OPENCLAW_TEST_FAST: "1",
|
|
});
|
|
|
|
expect(pinned).toEqual({
|
|
configPath: selectedConfigPath,
|
|
stateDir: selectedStateDir,
|
|
});
|
|
expect(CONFIG_PATH).toBe(selectedConfigPath);
|
|
expect(isNixMode).toBe(true);
|
|
expect(STATE_DIR).toBe(selectedStateDir);
|
|
} finally {
|
|
pinRuntimePaths({
|
|
OPENCLAW_CONFIG_PATH: originalConfigPath,
|
|
OPENCLAW_NIX_MODE: originalNixMode ? "1" : undefined,
|
|
OPENCLAW_STATE_DIR: originalStateDir,
|
|
OPENCLAW_TEST_FAST: "1",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("uses OPENCLAW_HOME for default state/config locations", () => {
|
|
const env = {
|
|
OPENCLAW_HOME: "/srv/openclaw-home",
|
|
} as NodeJS.ProcessEnv;
|
|
expectOpenClawHomeDefaults(env);
|
|
});
|
|
|
|
it("prefers OPENCLAW_HOME over HOME for default state/config locations", () => {
|
|
const env = {
|
|
OPENCLAW_HOME: "/srv/openclaw-home",
|
|
HOME: "/home/other",
|
|
} as NodeJS.ProcessEnv;
|
|
expectOpenClawHomeDefaults(env);
|
|
});
|
|
|
|
it("orders default config candidates in a stable order", () => {
|
|
const home = "/home/test";
|
|
const resolvedHome = path.resolve(home);
|
|
const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home);
|
|
const expected = [
|
|
path.join(resolvedHome, ".openclaw", "openclaw.json"),
|
|
path.join(resolvedHome, ".openclaw", "clawdbot.json"),
|
|
path.join(resolvedHome, ".clawdbot", "openclaw.json"),
|
|
path.join(resolvedHome, ".clawdbot", "clawdbot.json"),
|
|
];
|
|
expect(candidates).toEqual(expected);
|
|
});
|
|
|
|
it("prefers ~/.openclaw when it exists and legacy dir is missing", async () => {
|
|
await withTempDir({ prefix: "openclaw-state-" }, async (root) => {
|
|
const newDir = path.join(root, ".openclaw");
|
|
await fs.mkdir(newDir, { recursive: true });
|
|
const resolved = resolveStateDir({} as NodeJS.ProcessEnv, () => root);
|
|
expect(resolved).toBe(newDir);
|
|
});
|
|
});
|
|
|
|
it("falls back to existing legacy state dir when ~/.openclaw is missing", async () => {
|
|
await withTempDir({ prefix: "openclaw-state-legacy-" }, async (root) => {
|
|
const legacyDir = path.join(root, ".clawdbot");
|
|
await fs.mkdir(legacyDir, { recursive: true });
|
|
const resolved = resolveStateDir({} as NodeJS.ProcessEnv, () => root);
|
|
expect(resolved).toBe(legacyDir);
|
|
});
|
|
});
|
|
|
|
it("CONFIG_PATH prefers existing config when present", async () => {
|
|
await withTempDir({ prefix: "openclaw-config-" }, async (root) => {
|
|
const legacyDir = path.join(root, ".openclaw");
|
|
await fs.mkdir(legacyDir, { recursive: true });
|
|
const legacyPath = path.join(legacyDir, "openclaw.json");
|
|
await fs.writeFile(legacyPath, "{}", "utf-8");
|
|
|
|
const resolved = resolveConfigPathCandidate({} as NodeJS.ProcessEnv, () => root);
|
|
expect(resolved).toBe(legacyPath);
|
|
});
|
|
});
|
|
|
|
it("respects state dir overrides when config is missing", async () => {
|
|
await withTempDir({ prefix: "openclaw-config-override-" }, async (root) => {
|
|
const legacyDir = path.join(root, ".openclaw");
|
|
await fs.mkdir(legacyDir, { recursive: true });
|
|
const legacyConfig = path.join(legacyDir, "openclaw.json");
|
|
await fs.writeFile(legacyConfig, "{}", "utf-8");
|
|
|
|
const overrideDir = path.join(root, "override");
|
|
const env = { OPENCLAW_STATE_DIR: overrideDir } as NodeJS.ProcessEnv;
|
|
const resolved = resolveConfigPath(env, overrideDir, () => root);
|
|
expect(resolved).toBe(path.join(overrideDir, "openclaw.json"));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveIncludeRoots", () => {
|
|
const HOME = path.parse(process.cwd()).root + "fakehome";
|
|
|
|
it("returns an empty list when OPENCLAW_INCLUDE_ROOTS is unset or blank", () => {
|
|
expect(resolveIncludeRoots(envWith({}), () => HOME)).toStrictEqual([]);
|
|
expect(resolveIncludeRoots(envWith({ OPENCLAW_INCLUDE_ROOTS: "" }), () => HOME)).toStrictEqual(
|
|
[],
|
|
);
|
|
expect(
|
|
resolveIncludeRoots(envWith({ OPENCLAW_INCLUDE_ROOTS: " " }), () => HOME),
|
|
).toStrictEqual([]);
|
|
});
|
|
|
|
it("splits on the platform path delimiter and resolves each entry to an absolute path", () => {
|
|
const a = path.resolve(path.parse(process.cwd()).root, "shared", "a");
|
|
const b = path.resolve(path.parse(process.cwd()).root, "shared", "b");
|
|
const env = envWith({ OPENCLAW_INCLUDE_ROOTS: [a, b].join(path.delimiter) });
|
|
expect(resolveIncludeRoots(env, () => HOME)).toEqual([a, b]);
|
|
});
|
|
|
|
it("expands a leading tilde in each entry using the resolved home dir", () => {
|
|
const env = envWith({ OPENCLAW_INCLUDE_ROOTS: "~/share/openclaw" });
|
|
expect(resolveIncludeRoots(env, () => HOME)).toEqual([path.join(HOME, "share", "openclaw")]);
|
|
});
|
|
|
|
it("drops empty entries and preserves de-duplicated order for repeated roots", () => {
|
|
const a = path.resolve(path.parse(process.cwd()).root, "shared", "a");
|
|
const env = envWith({
|
|
OPENCLAW_INCLUDE_ROOTS: ["", a, " ", a].join(path.delimiter),
|
|
});
|
|
expect(resolveIncludeRoots(env, () => HOME)).toEqual([a]);
|
|
});
|
|
});
|