From 6fc6fd0add874e527287fe0b6941285aba5bff1c Mon Sep 17 00:00:00 2001 From: Tristan Manchester Date: Mon, 2 Mar 2026 20:31:12 +0100 Subject: [PATCH] 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(