diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index a896a7a3f76..c385282fb52 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -1,6 +1,6 @@ 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 +22,10 @@ const configState = vi.hoisted(() => ({ cfg: {} as Record, snapshot: { exists: false } as Record, })); +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 +33,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 +142,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 +169,10 @@ describe("gateway run option collisions", () => { ); } + async function expectGatewayExit(argv: string[]) { + await expect(runGatewayCli(argv)).rejects.toThrow("__exit__:1"); + } + it("forwards parent-captured options to `gateway run` subcommand", async () => { await runGatewayCli([ "gateway", @@ -303,4 +322,44 @@ describe("gateway run option collisions", () => { 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 }); + }); }); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 0aa0e8ff36e..b79f621df23 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -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,40 @@ 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"), + }; +} + 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 +201,37 @@ async function runGatewayCommand(opts: GatewayRunOpts) { return; } + if (opts.reset && devMode) { + const paths = resolveDevResetPaths(process.env); + const resolvedStateDir = path.resolve(paths.stateDir); + const resolvedConfigPath = path.resolve(paths.configPath); + const stateIsDefault = resolvedStateDir === path.resolve(paths.defaultStateDir); + const configIsDefault = resolvedConfigPath === path.resolve(paths.defaultConfigPath); + const stateMatchesDev = resolvedStateDir === path.resolve(paths.expectedDevStateDir); + const configMatchesDev = resolvedConfigPath === path.resolve(paths.expectedDevConfigPath); + const hasStateOverride = Boolean(process.env.OPENCLAW_STATE_DIR?.trim()); + const hasConfigOverride = Boolean(process.env.OPENCLAW_CONFIG_PATH?.trim()); + const hasExplicitCustomTarget = + (hasStateOverride || hasConfigOverride) && !stateIsDefault && !configIsDefault; + + 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 +378,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) { diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 3351df22dd4..d117dfac4a7 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -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,46 @@ 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("parses --profile value and strips it", () => { const res = parseCliProfileArgs(["node", "openclaw", "--profile", "work", "status"]); if (!res.ok) { @@ -66,7 +106,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 = { OPENCLAW_STATE_DIR: "/custom", OPENCLAW_GATEWAY_PORT: "19099", @@ -77,10 +117,42 @@ describe("applyCliProfileEnv", () => { homedir: () => "/home/peter", }); expect(env.OPENCLAW_STATE_DIR).toBe("/custom"); - expect(env.OPENCLAW_GATEWAY_PORT).toBe("19099"); + // OPENCLAW_GATEWAY_PORT is intentionally reset for profile isolation. + expect(env.OPENCLAW_GATEWAY_PORT).toBe("19001"); expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join("/custom", "openclaw.json")); }); + it("clears inherited OPENCLAW_GATEWAY_PORT for non-dev profiles", () => { + const env: Record = { + OPENCLAW_GATEWAY_PORT: "18789", + }; + applyCliProfileEnv({ + profile: "work", + env, + homedir: () => "/home/peter", + }); + expect(env.OPENCLAW_GATEWAY_PORT).toBeUndefined(); + }); + + it("clears inherited service env vars for profile isolation", () => { + const env: Record = { + 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).toBeUndefined(); + expect(env.OPENCLAW_LAUNCHD_LABEL).toBeUndefined(); + expect(env.OPENCLAW_SYSTEMD_UNIT).toBeUndefined(); + expect(env.OPENCLAW_SERVICE_VERSION).toBeUndefined(); + expect(env.OPENCLAW_PROFILE).toBe("work"); + }); + it("uses OPENCLAW_HOME when deriving profile state dir", () => { const env: Record = { OPENCLAW_HOME: "/srv/openclaw-home", @@ -100,6 +172,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([ { diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 8948ab43f6a..69b7d4b8acb 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -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, @@ -40,7 +44,7 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { continue; } - if (sawCommand) { + if (sawCommand && arg !== "--profile" && !arg.startsWith("--profile=")) { out.push(arg); continue; } @@ -88,6 +92,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, @@ -110,7 +135,12 @@ export function applyCliProfileEnv(params: { } // Convenience only: fill defaults, never override explicit env values. + // Exception: clear inherited service-scoped env vars for profile isolation. env.OPENCLAW_PROFILE = profile; + delete env.OPENCLAW_GATEWAY_PORT; + delete env.OPENCLAW_LAUNCHD_LABEL; + delete env.OPENCLAW_SYSTEMD_UNIT; + delete env.OPENCLAW_SERVICE_VERSION; const stateDir = env.OPENCLAW_STATE_DIR?.trim() || resolveProfileStateDir(profile, env, homedir); if (!env.OPENCLAW_STATE_DIR?.trim()) { @@ -121,7 +151,7 @@ export function applyCliProfileEnv(params: { env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json"); } - if (profile === "dev" && !env.OPENCLAW_GATEWAY_PORT?.trim()) { + if (profile === "dev") { env.OPENCLAW_GATEWAY_PORT = "19001"; } } diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 3f70ccccfcb..507e71fda2d 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -1,7 +1,9 @@ +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeGatewayTokenInput, openUrl, + resolveResetTargets, resolveBrowserOpenCommand, resolveControlUiLinks, validateGatewayPasswordInput, @@ -153,3 +155,31 @@ 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")); + }); +}); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 6e029531f50..67416429df1 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -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 { resolveConfigPath, 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: resolveConfigPath(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); } diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index b8afe7674cb..d2578bf8b75 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -3,9 +3,11 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { + DEFAULT_GATEWAY_PORT, resolveDefaultConfigCandidates, resolveConfigPathCandidate, resolveConfigPath, + resolveGatewayPort, resolveOAuthDir, resolveOAuthPath, resolveStateDir, @@ -150,3 +152,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[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[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[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[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[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[0]; + expect(resolveGatewayPort(cfg, env)).toBe(18790); + }); +}); diff --git a/src/entry.ts b/src/entry.ts index 14a839f38b9..d8d2e249513 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -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"; @@ -112,7 +116,6 @@ if ( } process.exit(code ?? 1); }); - child.once("error", (error) => { console.error( "[openclaw] Failed to respawn CLI:", @@ -173,10 +176,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) && !tryHandleRootHelpFastPath(process.argv)) {