Merge 1fae5ec32f84dddae84f784b0c2da3550fd62540 into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
Tristan Manchester 2026-03-21 04:08:57 +00:00 committed by GitHub
commit 7c46e40af5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 885 additions and 57 deletions

View File

@ -1,6 +1,9 @@
import fs from "node:fs";
import fsPromises from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
const startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({
@ -22,6 +25,10 @@ const configState = vi.hoisted(() => ({
cfg: {} as Record<string, unknown>,
snapshot: { exists: false } as Record<string, unknown>,
}));
const resolveStateDir = vi.fn<(env?: NodeJS.ProcessEnv) => string>(() => "/tmp");
const resolveConfigPath = vi.fn((_env: NodeJS.ProcessEnv, stateDir: string) => {
return `${stateDir}/openclaw.json`;
});
const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture();
@ -29,7 +36,8 @@ vi.mock("../../config/config.js", () => ({
getConfigPath: () => "/tmp/openclaw-test-missing-config.json",
loadConfig: () => configState.cfg,
readConfigFileSnapshot: async () => configState.snapshot,
resolveStateDir: () => "/tmp",
resolveConfigPath: (env: NodeJS.ProcessEnv, stateDir: string) => resolveConfigPath(env, stateDir),
resolveStateDir: (env?: NodeJS.ProcessEnv) => resolveStateDir(env),
resolveGatewayPort: () => 18789,
}));
@ -137,6 +145,16 @@ describe("gateway run option collisions", () => {
waitForPortBindable.mockClear();
ensureDevGatewayConfig.mockClear();
runGatewayLoop.mockClear();
resolveStateDir.mockReset();
resolveStateDir.mockReturnValue("/tmp");
resolveConfigPath.mockReset();
resolveConfigPath.mockImplementation((_env: NodeJS.ProcessEnv, stateDir: string) => {
return `${stateDir}/openclaw.json`;
});
});
afterEach(() => {
vi.unstubAllEnvs();
});
async function runGatewayCli(argv: string[]) {
@ -154,6 +172,24 @@ describe("gateway run option collisions", () => {
);
}
async function expectGatewayExit(argv: string[]) {
await expect(runGatewayCli(argv)).rejects.toThrow("__exit__:1");
}
async function withTempPasswordFile<T>(
password: string,
run: (params: { passwordFile: string }) => Promise<T>,
): Promise<T> {
const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-run-"));
const passwordFile = path.join(dir, "password.txt");
try {
await fsPromises.writeFile(passwordFile, password, "utf8");
return await run({ passwordFile });
} finally {
await fsPromises.rm(dir, { recursive: true, force: true });
}
}
it("forwards parent-captured options to `gateway run` subcommand", async () => {
await runGatewayCli([
"gateway",
@ -236,21 +272,17 @@ describe("gateway run option collisions", () => {
});
it("reads gateway password from --password-file", async () => {
await withTempSecretFiles(
"openclaw-gateway-run-",
{ password: "pw_from_file\n" },
async ({ passwordFile }) => {
await runGatewayCli([
"gateway",
"run",
"--auth",
"password",
"--password-file",
passwordFile ?? "",
"--allow-unconfigured",
]);
},
);
await withTempPasswordFile("pw_from_file\n", async ({ passwordFile }) => {
await runGatewayCli([
"gateway",
"run",
"--auth",
"password",
"--password-file",
passwordFile ?? "",
"--allow-unconfigured",
]);
});
expect(startGatewayServer).toHaveBeenCalledWith(
18789,
@ -283,24 +315,155 @@ describe("gateway run option collisions", () => {
});
it("rejects using both --password and --password-file", async () => {
await withTempSecretFiles(
"openclaw-gateway-run-",
{ password: "pw_from_file\n" },
async ({ passwordFile }) => {
await expect(
runGatewayCli([
"gateway",
"run",
"--password",
"pw_inline",
"--password-file",
passwordFile ?? "",
"--allow-unconfigured",
]),
).rejects.toThrow("__exit__:1");
},
);
await withTempPasswordFile("pw_from_file\n", async ({ passwordFile }) => {
await expect(
runGatewayCli([
"gateway",
"run",
"--password",
"pw_inline",
"--password-file",
passwordFile ?? "",
"--allow-unconfigured",
]),
).rejects.toThrow("__exit__:1");
});
expect(runtimeErrors).toContain("Use either --password or --password-file.");
});
it("hard-stops --dev --reset when target resolves to default profile paths", async () => {
vi.stubEnv("HOME", "/Users/test");
resolveStateDir.mockReturnValue("/Users/test/.openclaw");
resolveConfigPath.mockImplementation((_env: NodeJS.ProcessEnv, stateDir: string) => {
return `${stateDir}/openclaw.json`;
});
await expectGatewayExit(["gateway", "run", "--dev", "--reset"]);
expect(ensureDevGatewayConfig).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain(
"Refusing to run `gateway --dev --reset` because the reset target is not dev-isolated.",
);
expect(runtimeErrors.join("\n")).toContain("/Users/test/.openclaw");
});
it("allows --dev --reset when target resolves to dev profile paths", async () => {
vi.stubEnv("HOME", "/Users/test");
resolveStateDir.mockReturnValue("/Users/test/.openclaw-dev");
resolveConfigPath.mockImplementation((_env: NodeJS.ProcessEnv, stateDir: string) => {
return `${stateDir}/openclaw.json`;
});
await runGatewayCli(["gateway", "run", "--dev", "--reset", "--allow-unconfigured"]);
expect(ensureDevGatewayConfig).toHaveBeenCalledWith({ reset: true });
});
it("allows --dev --reset for explicit non-default custom state/config paths", async () => {
vi.stubEnv("HOME", "/Users/test");
vi.stubEnv("OPENCLAW_STATE_DIR", "/tmp/custom-dev");
vi.stubEnv("OPENCLAW_CONFIG_PATH", "/tmp/custom-dev/openclaw.json");
resolveStateDir.mockReturnValue("/tmp/custom-dev");
resolveConfigPath.mockReturnValue("/tmp/custom-dev/openclaw.json");
await runGatewayCli(["gateway", "run", "--dev", "--reset", "--allow-unconfigured"]);
expect(ensureDevGatewayConfig).toHaveBeenCalledWith({ reset: true });
});
it("allows --dev --reset for explicit non-default legacy state/config paths", async () => {
vi.stubEnv("HOME", "/Users/test");
vi.stubEnv("CLAWDBOT_STATE_DIR", "/tmp/custom-dev");
vi.stubEnv("CLAWDBOT_CONFIG_PATH", "/tmp/custom-dev/openclaw.json");
resolveStateDir.mockReturnValue("/tmp/custom-dev");
resolveConfigPath.mockReturnValue("/tmp/custom-dev/openclaw.json");
await runGatewayCli(["gateway", "run", "--dev", "--reset", "--allow-unconfigured"]);
expect(ensureDevGatewayConfig).toHaveBeenCalledWith({ reset: true });
});
it("hard-stops --dev --reset when state/config match non-dev profile defaults", async () => {
vi.stubEnv("HOME", "/Users/test");
vi.stubEnv("OPENCLAW_PROFILE", "work");
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/test/.openclaw-work");
vi.stubEnv("OPENCLAW_CONFIG_PATH", "/Users/test/.openclaw-work/openclaw.json");
resolveStateDir.mockReturnValue("/Users/test/.openclaw-work");
resolveConfigPath.mockReturnValue("/Users/test/.openclaw-work/openclaw.json");
await expectGatewayExit(["gateway", "run", "--dev", "--reset"]);
expect(ensureDevGatewayConfig).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain(
"Refusing to run `gateway --dev --reset` because the reset target is not dev-isolated.",
);
});
it("hard-stops --dev --reset when legacy env resolves to non-dev profile defaults", async () => {
vi.stubEnv("HOME", "/Users/test");
vi.stubEnv("OPENCLAW_PROFILE", "work");
vi.stubEnv("CLAWDBOT_STATE_DIR", "/Users/test/.openclaw-work");
vi.stubEnv("CLAWDBOT_CONFIG_PATH", "/Users/test/.openclaw-work/openclaw.json");
resolveStateDir.mockReturnValue("/Users/test/.openclaw-work");
resolveConfigPath.mockReturnValue("/Users/test/.openclaw-work/openclaw.json");
await expectGatewayExit(["gateway", "run", "--dev", "--reset"]);
expect(ensureDevGatewayConfig).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain(
"Refusing to run `gateway --dev --reset` because the reset target is not dev-isolated.",
);
});
it("hard-stops --dev --reset when state is profile-default but config is custom", async () => {
vi.stubEnv("HOME", "/Users/test");
vi.stubEnv("OPENCLAW_PROFILE", "work");
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/test/.openclaw-work");
vi.stubEnv("OPENCLAW_CONFIG_PATH", "/tmp/custom-dev/openclaw.json");
resolveStateDir.mockReturnValue("/Users/test/.openclaw-work");
resolveConfigPath.mockReturnValue("/tmp/custom-dev/openclaw.json");
await expectGatewayExit(["gateway", "run", "--dev", "--reset"]);
expect(ensureDevGatewayConfig).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain(
"Refusing to run `gateway --dev --reset` because the reset target is not dev-isolated.",
);
});
it("treats symlinked default paths as default reset targets", async () => {
const home = "/Users/test";
const defaultStateDir = path.join(home, ".openclaw");
const defaultConfigPath = path.join(defaultStateDir, "openclaw.json");
const aliasStateDir = path.join(home, ".openclaw-alias");
const aliasConfigPath = path.join(aliasStateDir, "openclaw.json");
const realpathSpy = vi.spyOn(fs, "realpathSync").mockImplementation((candidate) => {
const resolved = path.resolve(String(candidate));
if (resolved === path.resolve(aliasStateDir)) {
return path.resolve(defaultStateDir);
}
if (resolved === path.resolve(aliasConfigPath)) {
return path.resolve(defaultConfigPath);
}
return resolved;
});
vi.stubEnv("HOME", home);
vi.stubEnv("OPENCLAW_STATE_DIR", aliasStateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", aliasConfigPath);
resolveStateDir.mockReturnValue(aliasStateDir);
resolveConfigPath.mockReturnValue(aliasConfigPath);
try {
await expectGatewayExit(["gateway", "run", "--dev", "--reset"]);
} finally {
realpathSpy.mockRestore();
}
expect(ensureDevGatewayConfig).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain(
"Refusing to run `gateway --dev --reset` because the reset target is not dev-isolated.",
);
});
});

View File

@ -1,12 +1,13 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Command } from "commander";
import { readSecretFromFile } from "../../acp/secret-file.js";
import type { GatewayAuthMode, GatewayTailscaleMode } from "../../config/config.js";
import {
CONFIG_PATH,
loadConfig,
readConfigFileSnapshot,
resolveConfigPath,
resolveStateDir,
resolveGatewayPort,
} from "../../config/config.js";
@ -17,6 +18,7 @@ import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js";
import { setVerbose } from "../../globals.js";
import { GatewayLockError } from "../../infra/gateway-lock.js";
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js";
import { cleanStaleGatewayProcessesSync } from "../../infra/restart-stale-pids.js";
import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logging/console.js";
@ -25,6 +27,7 @@ import { defaultRuntime } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js";
import { inheritOptionFromParent } from "../command-options.js";
import { forceFreePortAndWait, waitForPortBindable } from "../ports.js";
import { isValidProfileName } from "../profile-utils.js";
import { ensureDevGatewayConfig } from "./dev.js";
import { runGatewayLoop } from "./run-loop.js";
import {
@ -157,8 +160,65 @@ function resolveGatewayRunOptions(opts: GatewayRunOpts, command?: Command): Gate
return resolved;
}
function resolveDevResetPaths(env: NodeJS.ProcessEnv = process.env): {
stateDir: string;
configPath: string;
defaultStateDir: string;
defaultConfigPath: string;
expectedDevStateDir: string;
expectedDevConfigPath: string;
} {
const home = resolveRequiredHomeDir(env, os.homedir);
const stateDir = resolveStateDir(env);
const configPath = resolveConfigPath(env, stateDir);
const defaultStateDir = path.join(home, ".openclaw");
const expectedDevStateDir = path.join(home, ".openclaw-dev");
return {
stateDir,
configPath,
defaultStateDir,
defaultConfigPath: path.join(defaultStateDir, "openclaw.json"),
expectedDevStateDir,
expectedDevConfigPath: path.join(expectedDevStateDir, "openclaw.json"),
};
}
function canonicalizePathForCompare(rawPath: string): string {
const resolvedPath = path.resolve(rawPath);
try {
return fs.realpathSync(resolvedPath);
} catch {
return resolvedPath;
}
}
function resolveProfileDefaultPaths(
profile: string,
env: NodeJS.ProcessEnv = process.env,
): {
stateDir: string;
configPath: string;
} {
const home = resolveRequiredHomeDir(env, os.homedir);
const suffix = profile.toLowerCase() === "default" ? "" : `-${profile}`;
const stateDir = path.join(home, `.openclaw${suffix}`);
return {
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
};
}
async function runGatewayCommand(opts: GatewayRunOpts) {
const isDevProfile = process.env.OPENCLAW_PROFILE?.trim().toLowerCase() === "dev";
const envProfile = process.env.OPENCLAW_PROFILE?.trim();
if (envProfile && !isValidProfileName(envProfile)) {
defaultRuntime.error(
'Invalid OPENCLAW_PROFILE (use letters, numbers, "_", "-" only, or unset the variable).',
);
defaultRuntime.exit(1);
return;
}
const isDevProfile = envProfile?.toLowerCase() === "dev";
const devMode = Boolean(opts.dev) || isDevProfile;
if (opts.reset && !devMode) {
defaultRuntime.error("Use --reset with --dev.");
@ -166,6 +226,57 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
return;
}
if (opts.reset && devMode) {
const paths = resolveDevResetPaths(process.env);
const resolvedStateDir = canonicalizePathForCompare(paths.stateDir);
const resolvedConfigPath = canonicalizePathForCompare(paths.configPath);
const stateIsDefault = resolvedStateDir === canonicalizePathForCompare(paths.defaultStateDir);
const configIsDefault =
resolvedConfigPath === canonicalizePathForCompare(paths.defaultConfigPath);
const stateMatchesDev =
resolvedStateDir === canonicalizePathForCompare(paths.expectedDevStateDir);
const configMatchesDev =
resolvedConfigPath === canonicalizePathForCompare(paths.expectedDevConfigPath);
const profileDefaultPaths = envProfile
? resolveProfileDefaultPaths(envProfile, process.env)
: null;
const stateMatchesProfileDefault = profileDefaultPaths
? resolvedStateDir === canonicalizePathForCompare(profileDefaultPaths.stateDir)
: false;
const configMatchesProfileDefault = profileDefaultPaths
? resolvedConfigPath === canonicalizePathForCompare(profileDefaultPaths.configPath)
: false;
const stateTargetsProfileDefault = stateIsDefault || stateMatchesProfileDefault;
const configTargetsProfileDefault = configIsDefault || configMatchesProfileDefault;
const hasStateOverride = Boolean(
process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim(),
);
const hasConfigOverride = Boolean(
process.env.OPENCLAW_CONFIG_PATH?.trim() || process.env.CLAWDBOT_CONFIG_PATH?.trim(),
);
const hasExplicitCustomTarget =
hasStateOverride &&
!stateTargetsProfileDefault &&
(!hasConfigOverride || !configTargetsProfileDefault);
if (!hasExplicitCustomTarget && (!stateMatchesDev || !configMatchesDev)) {
defaultRuntime.error(
[
"Refusing to run `gateway --dev --reset` because the reset target is not dev-isolated.",
`Resolved state dir: ${paths.stateDir}`,
`Resolved config path: ${paths.configPath}`,
`Expected dev state dir: ${paths.expectedDevStateDir}`,
`Expected dev config path: ${paths.expectedDevConfigPath}`,
"Retry with:",
" openclaw --dev gateway --dev --reset",
` OPENCLAW_STATE_DIR="${paths.expectedDevStateDir}" OPENCLAW_CONFIG_PATH="${paths.expectedDevConfigPath}" openclaw gateway --dev --reset`,
].join("\n"),
);
defaultRuntime.exit(1);
return;
}
}
setConsoleTimestampPrefix(true);
setVerbose(Boolean(opts.verbose));
if (opts.claudeCliLogs) {
@ -312,8 +423,10 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
const tokenRaw = toOptionString(opts.token);
const snapshot = await readConfigFileSnapshot().catch(() => null);
const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH);
const configAuditPath = path.join(resolveStateDir(process.env), "logs", "config-audit.jsonl");
const stateDir = resolveStateDir(process.env);
const configPath = resolveConfigPath(process.env, stateDir);
const configExists = snapshot?.exists ?? fs.existsSync(configPath);
const configAuditPath = path.join(stateDir, "logs", "config-audit.jsonl");
const mode = cfg.gateway?.mode;
if (!opts.allowUnconfigured && mode !== "local") {
if (!configExists) {

View File

@ -1,7 +1,7 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { formatCliCommand } from "./command-format.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import { applyCliProfileEnv, parseCliProfileArgs, resolveEffectiveCliProfile } from "./profile.js";
describe("parseCliProfileArgs", () => {
it("leaves gateway --dev for subcommands", () => {
@ -28,6 +28,243 @@ describe("parseCliProfileArgs", () => {
expect(res.argv).toEqual(["node", "openclaw", "gateway"]);
});
it("parses --profile after subcommand", () => {
const res = parseCliProfileArgs([
"node",
"openclaw",
"gateway",
"--profile",
"invest",
"--port",
"18795",
]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBe("invest");
expect(res.argv).toEqual(["node", "openclaw", "gateway", "--port", "18795"]);
});
it("parses --profile=NAME after subcommand", () => {
const res = parseCliProfileArgs(["node", "openclaw", "gateway", "--profile=work"]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBe("work");
expect(res.argv).toEqual(["node", "openclaw", "gateway"]);
});
it("keeps --dev and strips --profile when both appear after subcommand", () => {
const res = parseCliProfileArgs(["node", "openclaw", "gateway", "--dev", "--profile", "work"]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBe("work");
expect(res.argv).toEqual(["node", "openclaw", "gateway", "--dev"]);
});
it("rejects --dev before subcommand combined with --profile after subcommand", () => {
const res = parseCliProfileArgs(["node", "openclaw", "--dev", "gateway", "--profile", "work"]);
expect(res.ok).toBe(false);
});
it("does not intercept --profile after passthrough terminator", () => {
const res = parseCliProfileArgs([
"node",
"openclaw",
"nodes",
"run",
"--node",
"abc123",
"--",
"aws",
"--profile",
"prod",
"sts",
"get-caller-identity",
]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBeNull();
expect(res.argv).toEqual([
"node",
"openclaw",
"nodes",
"run",
"--node",
"abc123",
"--",
"aws",
"--profile",
"prod",
"sts",
"get-caller-identity",
]);
});
it("does not intercept --profile in nodes run argv without passthrough terminator", () => {
const res = parseCliProfileArgs([
"node",
"openclaw",
"nodes",
"run",
"--node",
"abc123",
"aws",
"--profile",
"prod",
"sts",
"get-caller-identity",
]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBeNull();
expect(res.argv).toEqual([
"node",
"openclaw",
"nodes",
"run",
"--node",
"abc123",
"aws",
"--profile",
"prod",
"sts",
"get-caller-identity",
]);
});
it("does not intercept --profile in docs query args", () => {
const res = parseCliProfileArgs(["node", "openclaw", "docs", "aws", "--profile", "prod"]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBeNull();
expect(res.argv).toEqual(["node", "openclaw", "docs", "aws", "--profile", "prod"]);
});
it("does not intercept --profile in acp client server args", () => {
const res = parseCliProfileArgs([
"node",
"openclaw",
"acp",
"client",
"--server-args",
"--profile",
"work",
]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBeNull();
expect(res.argv).toEqual([
"node",
"openclaw",
"acp",
"client",
"--server-args",
"--profile",
"work",
]);
});
it("preserves acp client server args after root option values", () => {
const res = parseCliProfileArgs([
"node",
"openclaw",
"--log-level",
"debug",
"acp",
"client",
"--server-args",
"--profile",
"work",
]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBeNull();
expect(res.argv).toEqual([
"node",
"openclaw",
"--log-level",
"debug",
"acp",
"client",
"--server-args",
"--profile",
"work",
]);
});
it("keeps passthrough --profile when global --profile is set before terminator", () => {
const res = parseCliProfileArgs([
"node",
"openclaw",
"--profile",
"work",
"nodes",
"run",
"--",
"aws",
"--profile=prod",
"sts",
"get-caller-identity",
]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBe("work");
expect(res.argv).toEqual([
"node",
"openclaw",
"nodes",
"run",
"--",
"aws",
"--profile=prod",
"sts",
"get-caller-identity",
]);
});
it("consumes root option values before command-path passthrough guard", () => {
const res = parseCliProfileArgs([
"node",
"openclaw",
"--log-level",
"debug",
"nodes",
"run",
"--",
"aws",
"--profile",
"prod",
"sts",
"get-caller-identity",
]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBeNull();
expect(res.argv).toEqual([
"node",
"openclaw",
"--log-level",
"debug",
"nodes",
"run",
"--",
"aws",
"--profile",
"prod",
"sts",
"get-caller-identity",
]);
});
it("parses --profile value and strips it", () => {
const res = parseCliProfileArgs(["node", "openclaw", "--profile", "work", "status"]);
if (!res.ok) {
@ -66,7 +303,7 @@ describe("applyCliProfileEnv", () => {
expect(env.OPENCLAW_GATEWAY_PORT).toBe("19001");
});
it("does not override explicit env values", () => {
it("does not override explicit OPENCLAW_STATE_DIR env value", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_STATE_DIR: "/custom",
OPENCLAW_GATEWAY_PORT: "19099",
@ -81,6 +318,47 @@ describe("applyCliProfileEnv", () => {
expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join("/custom", "openclaw.json"));
});
it("preserves explicit OPENCLAW_GATEWAY_PORT for non-dev profiles", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_GATEWAY_PORT: "18789",
};
applyCliProfileEnv({
profile: "work",
env,
homedir: () => "/home/peter",
});
expect(env.OPENCLAW_GATEWAY_PORT).toBe("18789");
});
it("preserves service override env vars", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_GATEWAY_PORT: "18789",
OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway",
OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway.service",
OPENCLAW_SERVICE_VERSION: "2026.1.0",
};
applyCliProfileEnv({
profile: "work",
env,
homedir: () => "/home/peter",
});
expect(env.OPENCLAW_GATEWAY_PORT).toBe("18789");
expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.gateway");
expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway.service");
expect(env.OPENCLAW_SERVICE_VERSION).toBe("2026.1.0");
expect(env.OPENCLAW_PROFILE).toBe("work");
});
it("uses 19001 for dev profile only when OPENCLAW_GATEWAY_PORT is unset", () => {
const env: Record<string, string | undefined> = {};
applyCliProfileEnv({
profile: "dev",
env,
homedir: () => "/home/peter",
});
expect(env.OPENCLAW_GATEWAY_PORT).toBe("19001");
});
it("uses OPENCLAW_HOME when deriving profile state dir", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_HOME: "/srv/openclaw-home",
@ -100,6 +378,40 @@ describe("applyCliProfileEnv", () => {
});
});
describe("resolveEffectiveCliProfile", () => {
it("prefers parsed profile over env profile", () => {
const res = resolveEffectiveCliProfile({
parsedProfile: "work",
envProfile: "dev",
});
expect(res).toEqual({ ok: true, profile: "work" });
});
it("falls back to env profile when parsed profile is absent", () => {
const res = resolveEffectiveCliProfile({
parsedProfile: null,
envProfile: "dev",
});
expect(res).toEqual({ ok: true, profile: "dev" });
});
it("treats blank env profile as unset", () => {
const res = resolveEffectiveCliProfile({
parsedProfile: null,
envProfile: " ",
});
expect(res).toEqual({ ok: true, profile: null });
});
it("rejects invalid env profile values", () => {
const res = resolveEffectiveCliProfile({
parsedProfile: null,
envProfile: "bad profile",
});
expect(res.ok).toBe(false);
});
});
describe("formatCliCommand", () => {
it.each([
{

View File

@ -7,6 +7,10 @@ export type CliProfileParseResult =
| { ok: true; profile: string | null; argv: string[] }
| { ok: false; error: string };
export type EffectiveCliProfileResult =
| { ok: true; profile: string | null }
| { ok: false; error: string };
function takeValue(
raw: string,
next: string | undefined,
@ -23,6 +27,74 @@ function takeValue(
return { value: trimmed || null, consumedNext: Boolean(next) };
}
const ARBITRARY_ARG_COMMAND_PATHS = [["nodes", "run"], ["docs"], ["acp", "client"]] as const;
const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]);
const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level"]);
function isRootOptionValueToken(arg: string | undefined): boolean {
if (!arg || arg === "--") {
return false;
}
if (!arg.startsWith("-")) {
return true;
}
return /^-\d+(?:\.\d+)?$/.test(arg);
}
function shouldGuardTrailingArgsFromProfileParsing(args: string[]): boolean {
const commandTokens: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg || arg === "--") {
break;
}
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) {
continue;
}
if (ROOT_VALUE_FLAGS.has(arg)) {
const next = args[i + 1];
if (isRootOptionValueToken(next)) {
i += 1;
}
continue;
}
if (arg.startsWith("-")) {
continue;
}
commandTokens.push(arg);
if (
ARBITRARY_ARG_COMMAND_PATHS.some(
(commandPath) =>
commandPath.length === commandTokens.length &&
commandPath.every((part, idx) => part === commandTokens[idx]),
)
) {
return true;
}
const couldStillMatch = ARBITRARY_ARG_COMMAND_PATHS.some(
(commandPath) =>
commandTokens.length <= commandPath.length &&
commandTokens.every((part, idx) => commandPath[idx] === part),
);
if (!couldStillMatch) {
return false;
}
}
return false;
}
export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
if (argv.length < 2) {
return { ok: true, profile: null, argv };
@ -32,15 +104,43 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
let profile: string | null = null;
let sawDev = false;
let sawCommand = false;
let sawTerminator = false;
const args = argv.slice(2);
const guardTrailingArgs = shouldGuardTrailingArgsFromProfileParsing(args);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === undefined) {
continue;
}
if (sawCommand) {
if (sawTerminator) {
out.push(arg);
continue;
}
if (arg === "--") {
sawTerminator = true;
out.push(arg);
continue;
}
if (arg === "--log-level" || arg.startsWith("--log-level=")) {
out.push(arg);
if (arg === "--log-level") {
const next = args[i + 1];
if (isRootOptionValueToken(next)) {
out.push(next);
i += 1;
}
}
continue;
}
if (
sawCommand &&
(guardTrailingArgs || (arg !== "--profile" && !arg.startsWith("--profile=")))
) {
out.push(arg);
continue;
}
@ -88,6 +188,27 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
return { ok: true, profile, argv: out };
}
export function resolveEffectiveCliProfile(params: {
parsedProfile: string | null;
envProfile?: string;
}): EffectiveCliProfileResult {
if (params.parsedProfile) {
return { ok: true, profile: params.parsedProfile };
}
const envProfile = params.envProfile?.trim() || "";
if (!envProfile) {
return { ok: true, profile: null };
}
if (!isValidProfileName(envProfile)) {
return {
ok: false,
error:
'Invalid OPENCLAW_PROFILE (use letters, numbers, "_", "-" only, or unset the variable)',
};
}
return { ok: true, profile: envProfile };
}
function resolveProfileStateDir(
profile: string,
env: Record<string, string | undefined>,

View File

@ -1,9 +1,11 @@
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
normalizeGatewayTokenInput,
openUrl,
resolveBrowserOpenCommand,
resolveControlUiLinks,
resolveResetTargets,
validateGatewayPasswordInput,
} from "./onboard-helpers.js";
@ -23,6 +25,11 @@ const mocks = vi.hoisted(() => ({
pickPrimaryTailnetIPv4: vi.fn<() => string | undefined>(() => undefined),
}));
vi.mock("@mariozechner/pi-ai", () => ({
getOAuthProviders: () => [],
getOAuthApiKey: vi.fn(async () => null),
}));
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: mocks.runCommandWithTimeout,
}));
@ -153,3 +160,44 @@ describe("validateGatewayPasswordInput", () => {
expect(validateGatewayPasswordInput(" secret ")).toBeUndefined();
});
});
describe("resolveResetTargets", () => {
it("derives reset targets from the active OPENCLAW_STATE_DIR", () => {
const env = {
OPENCLAW_STATE_DIR: "/tmp/custom-openclaw",
} as NodeJS.ProcessEnv;
const targets = resolveResetTargets(env);
const expectedStateDir = path.resolve("/tmp/custom-openclaw");
expect(targets.stateDir).toBe(expectedStateDir);
expect(targets.configPath).toBe(path.join(expectedStateDir, "openclaw.json"));
expect(targets.credentialsPath).toBe(path.join(expectedStateDir, "credentials"));
expect(targets.sessionsDir).toBe(path.join(expectedStateDir, "agents", "main", "sessions"));
});
it("respects OPENCLAW_CONFIG_PATH override at runtime", () => {
const env = {
OPENCLAW_STATE_DIR: "/tmp/custom-openclaw",
OPENCLAW_CONFIG_PATH: "/tmp/alternate/openclaw.json",
} as NodeJS.ProcessEnv;
const targets = resolveResetTargets(env);
expect(targets.stateDir).toBe(path.resolve("/tmp/custom-openclaw"));
expect(targets.configPath).toBe(path.resolve("/tmp/alternate/openclaw.json"));
expect(targets.credentialsPath).toBe(path.resolve("/tmp/custom-openclaw/credentials"));
expect(targets.sessionsDir).toBe(path.resolve("/tmp/custom-openclaw/agents/main/sessions"));
});
it("respects legacy CLAWDBOT_CONFIG_PATH override at runtime", () => {
const env = {
OPENCLAW_STATE_DIR: "/tmp/custom-openclaw",
CLAWDBOT_CONFIG_PATH: "/tmp/legacy/openclaw.json",
} as NodeJS.ProcessEnv;
const targets = resolveResetTargets(env);
expect(targets.stateDir).toBe(path.resolve("/tmp/custom-openclaw"));
expect(targets.configPath).toBe(path.resolve("/tmp/legacy/openclaw.json"));
expect(targets.credentialsPath).toBe(path.resolve("/tmp/custom-openclaw/credentials"));
expect(targets.sessionsDir).toBe(path.resolve("/tmp/custom-openclaw/agents/main/sessions"));
});
});

View File

@ -5,7 +5,7 @@ import { inspect } from "node:util";
import { cancel, isCancel } from "@clack/prompts";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH } from "../config/config.js";
import { resolveCanonicalConfigPath, resolveStateDir } from "../config/config.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
@ -17,13 +17,7 @@ import { isWSL } from "../infra/wsl.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import {
CONFIG_DIR,
resolveUserPath,
shortenHomeInString,
shortenHomePath,
sleep,
} from "../utils.js";
import { resolveUserPath, shortenHomeInString, shortenHomePath, sleep } from "../utils.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { VERSION } from "../version.js";
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
@ -329,13 +323,32 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
}
}
export function resolveResetTargets(
env: NodeJS.ProcessEnv = process.env,
agentId?: string,
): {
stateDir: string;
configPath: string;
credentialsPath: string;
sessionsDir: string;
} {
const stateDir = resolveStateDir(env);
return {
stateDir,
configPath: resolveCanonicalConfigPath(env, stateDir),
credentialsPath: path.join(stateDir, "credentials"),
sessionsDir: resolveSessionTranscriptsDirForAgent(agentId, env),
};
}
export async function handleReset(scope: ResetScope, workspaceDir: string, runtime: RuntimeEnv) {
await moveToTrash(CONFIG_PATH, runtime);
const targets = resolveResetTargets(process.env);
await moveToTrash(targets.configPath, runtime);
if (scope === "config") {
return;
}
await moveToTrash(path.join(CONFIG_DIR, "credentials"), runtime);
await moveToTrash(resolveSessionTranscriptsDirForAgent(), runtime);
await moveToTrash(targets.credentialsPath, runtime);
await moveToTrash(targets.sessionsDir, runtime);
if (scope === "full") {
await moveToTrash(workspaceDir, runtime);
}

View File

@ -3,9 +3,11 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
DEFAULT_GATEWAY_PORT,
resolveDefaultConfigCandidates,
resolveConfigPathCandidate,
resolveConfigPath,
resolveGatewayPort,
resolveOAuthDir,
resolveOAuthPath,
resolveStateDir,
@ -141,3 +143,44 @@ describe("state + config path candidates", () => {
});
});
});
describe("resolveGatewayPort", () => {
it("returns DEFAULT_GATEWAY_PORT when no env or config port is set", () => {
expect(resolveGatewayPort(undefined, {} as NodeJS.ProcessEnv)).toBe(DEFAULT_GATEWAY_PORT);
});
it("prefers OPENCLAW_GATEWAY_PORT env var over config", () => {
const env = { OPENCLAW_GATEWAY_PORT: "18790" } as NodeJS.ProcessEnv;
const cfg = { gateway: { port: 19001 } } as Parameters<typeof resolveGatewayPort>[0];
expect(resolveGatewayPort(cfg, env)).toBe(18790);
});
it("prefers CLAWDBOT_GATEWAY_PORT env var over config", () => {
const env = { CLAWDBOT_GATEWAY_PORT: "18791" } as NodeJS.ProcessEnv;
const cfg = { gateway: { port: 19001 } } as Parameters<typeof resolveGatewayPort>[0];
expect(resolveGatewayPort(cfg, env)).toBe(18791);
});
it("falls back to config port when env var is absent", () => {
const cfg = { gateway: { port: 18790 } } as Parameters<typeof resolveGatewayPort>[0];
expect(resolveGatewayPort(cfg, {} as NodeJS.ProcessEnv)).toBe(18790);
});
it("falls back to DEFAULT_GATEWAY_PORT when env and config are both missing", () => {
expect(
resolveGatewayPort({} as Parameters<typeof resolveGatewayPort>[0], {} as NodeJS.ProcessEnv),
).toBe(DEFAULT_GATEWAY_PORT);
});
it("ignores invalid env ports and falls back to config", () => {
const env = { OPENCLAW_GATEWAY_PORT: "abc" } as NodeJS.ProcessEnv;
const cfg = { gateway: { port: 18790 } } as Parameters<typeof resolveGatewayPort>[0];
expect(resolveGatewayPort(cfg, env)).toBe(18790);
});
it("ignores zero env ports and falls back to config", () => {
const env = { OPENCLAW_GATEWAY_PORT: "0" } as NodeJS.ProcessEnv;
const cfg = { gateway: { port: 18790 } } as Parameters<typeof resolveGatewayPort>[0];
expect(resolveGatewayPort(cfg, env)).toBe(18790);
});
});

View File

@ -4,7 +4,11 @@ import { enableCompileCache } from "node:module";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
import {
applyCliProfileEnv,
parseCliProfileArgs,
resolveEffectiveCliProfile,
} from "./cli/profile.js";
import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js";
import { normalizeWindowsArgv } from "./cli/windows-argv.js";
import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js";
@ -158,10 +162,21 @@ if (
process.exit(2);
}
if (parsed.profile) {
applyCliProfileEnv({ profile: parsed.profile });
const effectiveProfile = resolveEffectiveCliProfile({
parsedProfile: parsed.profile,
envProfile: process.env.OPENCLAW_PROFILE,
});
if (!effectiveProfile.ok) {
console.error(`[openclaw] ${effectiveProfile.error}`);
process.exit(2);
}
if (effectiveProfile.profile) {
applyCliProfileEnv({ profile: effectiveProfile.profile });
// Keep Commander and ad-hoc argv checks consistent.
process.argv = parsed.argv;
if (parsed.profile) {
process.argv = parsed.argv;
}
}
if (!tryHandleRootVersionFastPath(process.argv)) {