From 902396c8b6ffe8d5d3ac0da1f24fe7e942c703ad Mon Sep 17 00:00:00 2001 From: Tristan Manchester Date: Sun, 22 Feb 2026 06:48:13 +0100 Subject: [PATCH 1/8] Gateway: isolate dev reset from default profile state --- .../gateway-cli/run.option-collisions.test.ts | 63 +++++++++- src/cli/gateway-cli/run.ts | 76 +++++++++++- src/cli/profile.test.ts | 112 +++++++++++++++++- src/cli/profile.ts | 34 +++++- src/commands/onboard-helpers.test.ts | 30 +++++ src/commands/onboard-helpers.ts | 35 ++++-- src/config/paths.test.ts | 43 +++++++ src/entry.ts | 24 +++- 8 files changed, 390 insertions(+), 27 deletions(-) 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)) { From e13cc5b66baef7f492ecf2c8152820d5c83bdf28 Mon Sep 17 00:00:00 2001 From: Tristan Manchester Date: Sun, 22 Feb 2026 08:39:34 +0100 Subject: [PATCH 2/8] CLI: keep passthrough --profile args after -- --- src/cli/profile.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++ src/cli/profile.ts | 12 ++++++++ 2 files changed, 78 insertions(+) diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index d117dfac4a7..36067ab12f6 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -68,6 +68,72 @@ describe("parseCliProfileArgs", () => { 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("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("parses --profile value and strips it", () => { const res = parseCliProfileArgs(["node", "openclaw", "--profile", "work", "status"]); if (!res.ok) { diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 69b7d4b8acb..0ddc879675e 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -36,6 +36,7 @@ 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); for (let i = 0; i < args.length; i += 1) { @@ -44,6 +45,17 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { continue; } + if (sawTerminator) { + out.push(arg); + continue; + } + + if (arg === "--") { + sawTerminator = true; + out.push(arg); + continue; + } + if (sawCommand && arg !== "--profile" && !arg.startsWith("--profile=")) { out.push(arg); continue; From 0a3050323902f550121ee8095591634e5c4f8e38 Mon Sep 17 00:00:00 2001 From: Tristan Manchester Date: Sun, 1 Mar 2026 10:28:43 +0100 Subject: [PATCH 3/8] CLI: preserve profile passthrough args and env overrides --- src/cli/profile.test.ts | 69 +++++++++++++++++++++++++++++++++++------ src/cli/profile.ts | 64 +++++++++++++++++++++++++++++++++----- 2 files changed, 117 insertions(+), 16 deletions(-) diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 36067ab12f6..17204d89d6b 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -103,6 +103,48 @@ describe("parseCliProfileArgs", () => { ]); }); + 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("keeps passthrough --profile when global --profile is set before terminator", () => { const res = parseCliProfileArgs([ "node", @@ -183,12 +225,11 @@ describe("applyCliProfileEnv", () => { homedir: () => "/home/peter", }); expect(env.OPENCLAW_STATE_DIR).toBe("/custom"); - // OPENCLAW_GATEWAY_PORT is intentionally reset for profile isolation. - expect(env.OPENCLAW_GATEWAY_PORT).toBe("19001"); + expect(env.OPENCLAW_GATEWAY_PORT).toBe("19099"); expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join("/custom", "openclaw.json")); }); - it("clears inherited OPENCLAW_GATEWAY_PORT for non-dev profiles", () => { + it("preserves explicit OPENCLAW_GATEWAY_PORT for non-dev profiles", () => { const env: Record = { OPENCLAW_GATEWAY_PORT: "18789", }; @@ -197,10 +238,10 @@ describe("applyCliProfileEnv", () => { env, homedir: () => "/home/peter", }); - expect(env.OPENCLAW_GATEWAY_PORT).toBeUndefined(); + expect(env.OPENCLAW_GATEWAY_PORT).toBe("18789"); }); - it("clears inherited service env vars for profile isolation", () => { + it("preserves service override env vars", () => { const env: Record = { OPENCLAW_GATEWAY_PORT: "18789", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway", @@ -212,13 +253,23 @@ describe("applyCliProfileEnv", () => { 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_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 = {}; + 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 = { OPENCLAW_HOME: "/srv/openclaw-home", diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 0ddc879675e..8b831380fc2 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -27,6 +27,57 @@ function takeValue( return { value: trimmed || null, consumedNext: Boolean(next) }; } +const ARBITRARY_ARG_COMMAND_PATHS = [["nodes", "run"], ["docs"]] as const; + +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 (arg === "--dev") { + continue; + } + + if (arg === "--profile" || arg.startsWith("--profile=")) { + if (arg === "--profile") { + i += 1; + } + continue; + } + + if (arg.startsWith("-")) { + continue; + } + + commandTokens.push(arg); + + if ( + ARBITRARY_ARG_COMMAND_PATHS.some( + (path) => + path.length === commandTokens.length && + path.every((part, idx) => part === commandTokens[idx]), + ) + ) { + return true; + } + + const couldStillMatch = ARBITRARY_ARG_COMMAND_PATHS.some( + (path) => + commandTokens.length <= path.length && + commandTokens.every((part, idx) => path[idx] === part), + ); + if (!couldStillMatch) { + return false; + } + } + + return false; +} + export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { if (argv.length < 2) { return { ok: true, profile: null, argv }; @@ -39,6 +90,7 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { 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) { @@ -56,7 +108,10 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { continue; } - if (sawCommand && arg !== "--profile" && !arg.startsWith("--profile=")) { + if ( + sawCommand && + (guardTrailingArgs || (arg !== "--profile" && !arg.startsWith("--profile="))) + ) { out.push(arg); continue; } @@ -147,12 +202,7 @@ 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()) { @@ -163,7 +213,7 @@ export function applyCliProfileEnv(params: { env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json"); } - if (profile === "dev") { + if (profile === "dev" && !env.OPENCLAW_GATEWAY_PORT?.trim()) { env.OPENCLAW_GATEWAY_PORT = "19001"; } } From 6fc6fd0add874e527287fe0b6941285aba5bff1c Mon Sep 17 00:00:00 2001 From: Tristan Manchester Date: Mon, 2 Mar 2026 20:31:12 +0100 Subject: [PATCH 4/8] Gateway: harden dev reset safety checks --- .../gateway-cli/run.option-collisions.test.ts | 53 ++++++++++++++++++ src/cli/gateway-cli/run.ts | 55 ++++++++++++++++--- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index c385282fb52..3ce8077b3d1 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import { Command } from "commander"; import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -362,4 +364,55 @@ describe("gateway run option collisions", () => { 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("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.", + ); + }); }); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index b79f621df23..63151437d16 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -183,6 +183,31 @@ function resolveDevResetPaths(env: NodeJS.ProcessEnv = process.env): { }; } +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 envProfile = process.env.OPENCLAW_PROFILE?.trim(); if (envProfile && !isValidProfileName(envProfile)) { @@ -203,16 +228,32 @@ async function runGatewayCommand(opts: GatewayRunOpts) { 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 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 targetMatchesProfileDefaults = stateMatchesProfileDefault && configMatchesProfileDefault; const hasStateOverride = Boolean(process.env.OPENCLAW_STATE_DIR?.trim()); const hasConfigOverride = Boolean(process.env.OPENCLAW_CONFIG_PATH?.trim()); const hasExplicitCustomTarget = - (hasStateOverride || hasConfigOverride) && !stateIsDefault && !configIsDefault; + (hasStateOverride || hasConfigOverride) && + !stateIsDefault && + !configIsDefault && + !targetMatchesProfileDefaults; if (!hasExplicitCustomTarget && (!stateMatchesDev || !configMatchesDev)) { defaultRuntime.error( From 1e1aa9cffc792557ba40535bfcc7d6cb735a5746 Mon Sep 17 00:00:00 2001 From: Tristan Manchester Date: Mon, 2 Mar 2026 21:18:43 +0100 Subject: [PATCH 5/8] CLI: harden profile passthrough + dev reset interlock --- .../gateway-cli/run.option-collisions.test.ts | 16 +++++++++ src/cli/gateway-cli/run.ts | 10 +++--- src/cli/profile.test.ts | 35 +++++++++++++++++++ src/cli/profile.ts | 35 +++++++++++++++++-- 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 3ce8077b3d1..aaa306c160f 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -381,6 +381,22 @@ describe("gateway run option collisions", () => { ); }); + 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"); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 63151437d16..4f0e8a3dba3 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -246,14 +246,14 @@ async function runGatewayCommand(opts: GatewayRunOpts) { const configMatchesProfileDefault = profileDefaultPaths ? resolvedConfigPath === canonicalizePathForCompare(profileDefaultPaths.configPath) : false; - const targetMatchesProfileDefaults = stateMatchesProfileDefault && configMatchesProfileDefault; + const stateTargetsProfileDefault = stateIsDefault || stateMatchesProfileDefault; + const configTargetsProfileDefault = configIsDefault || configMatchesProfileDefault; const hasStateOverride = Boolean(process.env.OPENCLAW_STATE_DIR?.trim()); const hasConfigOverride = Boolean(process.env.OPENCLAW_CONFIG_PATH?.trim()); const hasExplicitCustomTarget = - (hasStateOverride || hasConfigOverride) && - !stateIsDefault && - !configIsDefault && - !targetMatchesProfileDefaults; + hasStateOverride && + !stateTargetsProfileDefault && + (!hasConfigOverride || !configTargetsProfileDefault); if (!hasExplicitCustomTarget && (!stateMatchesDev || !configMatchesDev)) { defaultRuntime.error( diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 17204d89d6b..14a66fd5b72 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -176,6 +176,41 @@ describe("parseCliProfileArgs", () => { ]); }); + 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) { diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 8b831380fc2..d371efbba84 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -28,6 +28,18 @@ function takeValue( } const ARBITRARY_ARG_COMMAND_PATHS = [["nodes", "run"], ["docs"]] 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[] = []; @@ -38,12 +50,17 @@ function shouldGuardTrailingArgsFromProfileParsing(args: string[]): boolean { break; } - if (arg === "--dev") { + if (ROOT_BOOLEAN_FLAGS.has(arg)) { continue; } - if (arg === "--profile" || arg.startsWith("--profile=")) { - if (arg === "--profile") { + 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; @@ -108,6 +125,18 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { 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="))) From 58d46b1736da9163270d89139bf4327655ad42b7 Mon Sep 17 00:00:00 2001 From: Tristan Manchester Date: Thu, 12 Mar 2026 09:09:12 +0100 Subject: [PATCH 6/8] CLI: refresh dev reset profile isolation PR --- src/cli/gateway-cli/run.option-collisions.test.ts | 1 + src/cli/profile.ts | 12 ++++++------ src/commands/onboard-helpers.test.ts | 2 +- src/entry.ts | 1 + 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index aaa306c160f..5fae91aed55 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { Command } from "commander"; import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js"; import { createCliRuntimeCapture } from "../test-runtime-capture.js"; const startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({ diff --git a/src/cli/profile.ts b/src/cli/profile.ts index d371efbba84..3d0ff84c5f9 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -74,18 +74,18 @@ function shouldGuardTrailingArgsFromProfileParsing(args: string[]): boolean { if ( ARBITRARY_ARG_COMMAND_PATHS.some( - (path) => - path.length === commandTokens.length && - path.every((part, idx) => part === commandTokens[idx]), + (commandPath) => + commandPath.length === commandTokens.length && + commandPath.every((part, idx) => part === commandTokens[idx]), ) ) { return true; } const couldStillMatch = ARBITRARY_ARG_COMMAND_PATHS.some( - (path) => - commandTokens.length <= path.length && - commandTokens.every((part, idx) => path[idx] === part), + (commandPath) => + commandTokens.length <= commandPath.length && + commandTokens.every((part, idx) => commandPath[idx] === part), ); if (!couldStillMatch) { return false; diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 507e71fda2d..a90daa17f03 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -3,9 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeGatewayTokenInput, openUrl, - resolveResetTargets, resolveBrowserOpenCommand, resolveControlUiLinks, + resolveResetTargets, validateGatewayPasswordInput, } from "./onboard-helpers.js"; diff --git a/src/entry.ts b/src/entry.ts index d8d2e249513..46f1fec291e 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -116,6 +116,7 @@ if ( } process.exit(code ?? 1); }); + child.once("error", (error) => { console.error( "[openclaw] Failed to respawn CLI:", From 8ccf40e36af672e054b7576c8c763b58a5fd00fb Mon Sep 17 00:00:00 2001 From: Tristan Manchester Date: Thu, 12 Mar 2026 09:13:09 +0100 Subject: [PATCH 7/8] Tests: align PR refresh fixtures with branch state --- .../gateway-cli/run.option-collisions.test.ts | 73 ++++++++++--------- src/commands/onboard-helpers.test.ts | 5 ++ 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 5fae91aed55..df7b99cf866 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -1,9 +1,10 @@ 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 { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js"; import { createCliRuntimeCapture } from "../test-runtime-capture.js"; const startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({ @@ -176,6 +177,20 @@ describe("gateway run option collisions", () => { await expect(runGatewayCli(argv)).rejects.toThrow("__exit__:1"); } + async function withTempPasswordFile( + password: string, + run: (params: { passwordFile: string }) => Promise, + ): Promise { + 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", @@ -258,21 +273,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, @@ -305,23 +316,19 @@ 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."); }); diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index a90daa17f03..1d28089ae63 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -25,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, })); From 1fae5ec32f84dddae84f784b0c2da3550fd62540 Mon Sep 17 00:00:00 2001 From: Tristan Manchester Date: Fri, 13 Mar 2026 04:46:58 +0100 Subject: [PATCH 8/8] CLI: harden profile passthrough and reset overrides --- .../gateway-cli/run.option-collisions.test.ts | 29 +++++++++- src/cli/gateway-cli/run.ts | 8 ++- src/cli/profile.test.ts | 54 +++++++++++++++++++ src/cli/profile.ts | 2 +- src/commands/onboard-helpers.test.ts | 13 +++++ src/commands/onboard-helpers.ts | 4 +- 6 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index df7b99cf866..c44fcb380f9 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -3,7 +3,6 @@ import fsPromises from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Command } from "commander"; -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"; @@ -373,6 +372,18 @@ describe("gateway run option collisions", () => { 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"); @@ -389,6 +400,22 @@ describe("gateway run option collisions", () => { ); }); + 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"); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 4f0e8a3dba3..306f1c773e7 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -248,8 +248,12 @@ async function runGatewayCommand(opts: GatewayRunOpts) { : false; const stateTargetsProfileDefault = stateIsDefault || stateMatchesProfileDefault; const configTargetsProfileDefault = configIsDefault || configMatchesProfileDefault; - const hasStateOverride = Boolean(process.env.OPENCLAW_STATE_DIR?.trim()); - const hasConfigOverride = Boolean(process.env.OPENCLAW_CONFIG_PATH?.trim()); + 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 && diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 14a66fd5b72..43e9f1d2991 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -145,6 +145,60 @@ describe("parseCliProfileArgs", () => { 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", diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 3d0ff84c5f9..b941b896a64 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -27,7 +27,7 @@ function takeValue( return { value: trimmed || null, consumedNext: Boolean(next) }; } -const ARBITRARY_ARG_COMMAND_PATHS = [["nodes", "run"], ["docs"]] as const; +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"]); diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 1d28089ae63..ac1dd82275a 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -187,4 +187,17 @@ describe("resolveResetTargets", () => { 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")); + }); }); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 67416429df1..a1fb7fd91bd 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 { resolveConfigPath, resolveStateDir } 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"; @@ -335,7 +335,7 @@ export function resolveResetTargets( const stateDir = resolveStateDir(env); return { stateDir, - configPath: resolveConfigPath(env, stateDir), + configPath: resolveCanonicalConfigPath(env, stateDir), credentialsPath: path.join(stateDir, "credentials"), sessionsDir: resolveSessionTranscriptsDirForAgent(agentId, env), };